资源分享应用全栈实践:从后端到多端客户端的完整实现

27 阅读10分钟

资源分享应用全栈实践:从后端到多端客户端的完整实现

小程序二维码.png

可以扫码查看应用功能

本文详细介绍了一个完整的资源分享系统的设计与实现,涵盖后端 API、管理后台、以及支持 H5/小程序/App 三端的客户端应用。适合准备做类似项目的开发者参考。

📋 目录


一、项目背景

1.1 业务需求

在内容管理和资源分享场景中,需要一个统一的资源管理系统,支持:

  • 资源分类管理:支持多级分类,灵活排序
  • 资源详情展示:富文本内容、图片展示、链接提取
  • 多端访问:H5、微信小程序、App 三端统一体验
  • 后台管理:便捷的资源增删改查、分类管理
  • 访问统计:自动记录资源访问量

1.2 技术选型

技术栈说明
后端Node.js + Koa + Sequelize + MySQLRESTful API,ORM 操作数据库
管理后台Vue 2 + Element UI后台资源管理界面
客户端uni-app + uView UI一套代码,多端打包

二、整体架构设计

2.1 系统架构图

┌─────────────────────────────────────────────────────────┐
│                     客户端层                              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│  │   H5     │  │  小程序   │  │   App    │            │
│  │ (uni-app)│  │ (uni-app) │  │ (uni-app)│            │
│  └──────────┘  └──────────┘  └──────────┘            │
└─────────────────────────────────────────────────────────┘
                        │
                        │ HTTP/HTTPS
                        ▼
┌─────────────────────────────────────────────────────────┐
│                      API 层                              │
│  ┌──────────────────────────────────────────────┐      │
│  │  /api/v1/client/resource/*  (客户端接口)     │      │
│  │  /api/v1/admin/resource/*   (管理端接口)    │      │
│  └──────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────┘
                        │
                        │ Sequelize ORM
                        ▼
┌─────────────────────────────────────────────────────────┐
│                     数据层                              │
│  ┌──────────────┐  ┌──────────────┐  ┌────────────┐ │
│  │ resource     │  │resource_detail│ │resource_   │ │
│  │ (资源主表)   │  │ (资源详情表)  │ │category    │ │
│  └──────────────┘  └──────────────┘  └────────────┘ │
│  ┌──────────────┐                                     │
│  │resource_config│ (资源配置表)                        │
│  └──────────────┘                                     │
└─────────────────────────────────────────────────────────┘

2.2 数据库设计

核心表结构

1. 资源分类表(resource_category)

CREATE TABLE `resource_category` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '分类主键ID',
  `name` varchar(100) NOT NULL COMMENT '分类名称',
  `sort` int DEFAULT 0 COMMENT '排序(数字越小越靠前)',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 资源主表(resource)

CREATE TABLE `resource` (
  `id` varchar(12) NOT NULL COMMENT '资源ID(随机12位字符)',
  `title` varchar(255) NOT NULL COMMENT '资源标题',
  `summary` text COMMENT '资源简介',
  `cover_img` varchar(255) COMMENT '封面图URL',
  `content_img` varchar(255) COMMENT '内容展示图URL',
  `size` varchar(50) COMMENT '资源大小',
  `price` varchar(50) COMMENT '资源价格',
  `is_top` tinyint(1) DEFAULT 0 COMMENT '是否置顶',
  `category_id` bigint COMMENT '分类ID',
  `status` tinyint(1) DEFAULT 1 COMMENT '状态 0-下架 1-上架',
  `view_count` int DEFAULT 0 COMMENT '访问量',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_category_id` (`category_id`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3. 资源详情表(resource_detail)

CREATE TABLE `resource_detail` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `resource_id` varchar(12) NOT NULL COMMENT '关联资源ID',
  `content` text COMMENT '详情内容(Markdown格式)',
  `password` varchar(500) COMMENT '提取码/链接(支持多个,用||分隔)',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_resource_id` (`resource_id`),
  CONSTRAINT `fk_detail_resource` FOREIGN KEY (`resource_id`)
    REFERENCES `resource` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4. 资源配置表(resource_config)

CREATE TABLE `resource_config` (
  `id` int NOT NULL AUTO_INCREMENT,
  `enable_list` tinyint(1) DEFAULT 1 COMMENT '是否开启资源列表',
  `description` text COMMENT '资源说明',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计亮点
  1. 随机 ID 设计:资源 ID 使用 12 位随机字符串,而非自增数字,避免暴露资源数量,提升安全性
  2. 表分离设计:主表存储基本信息,详情表存储富文本内容,提升查询性能
  3. 外键约束:使用外键保证数据一致性,支持级联删除
  4. 索引优化:为 category_idcreate_time 建立索引,提升查询效率

三、后端服务(Server)

3.1 技术栈

  • 框架:Koa.js(轻量级 Node.js Web 框架)
  • ORM:Sequelize(MySQL ORM)
  • 认证:JWT(jsonwebtoken)
  • 跨域:koa2-cors

3.2 目录结构

server/
├── app/
│   ├── api/
│   │   └── v1/
│   │       ├── index.js          # 客户端路由
│   │       └── admin.js          # 管理端路由
│   ├── controllers/
│   │   └── resource.js          # 客户端资源控制器
│   ├── controllers_admin/
│   │   └── resource.js          # 管理端资源控制器
│   ├── models/
│   │   ├── resource.js          # 资源模型
│   │   ├── resource_category.js # 分类模型
│   │   ├── resource_detail.js   # 详情模型
│   │   └── resource_config.js   # 配置模型
│   └── core/
│       └── DB.js                # 数据库连接
└── bin/
    └── www                      # 启动入口

3.3 核心接口实现

客户端接口(无需认证)

1. 资源列表接口

// GET /api/v1/client/resource/list
async list(ctx) {
  let { page = 1, size = 10, key = '', categoryId, isTop } = ctx.query

  const where = { status: 1 }

  // 关键字搜索:支持标题和简介
  if (key) {
    where[Op.or] = [
      { title: { [Op.like]: `%${key}%` } },
      { summary: { [Op.like]: `%${key}%` } }
    ]
  }

  // 分类筛选
  if (categoryId) {
    where.category_id = Number(categoryId)
  }

  // 置顶筛选
  if (isTop !== undefined) {
    where.is_top = Number(isTop)
  }

  const { count, rows } = await DB.resource.findAndCountAll({
    where,
    order: [['is_top', 'DESC'], ['create_time', 'DESC']],
    offset: (page - 1) * size,
    limit: size,
    include: [
      { model: DB.resource_category, as: 'category', attributes: ['id', 'name', 'sort'] }
    ]
  })

  global.Response.success(ctx, undefined, {
    rows,
    meta: { page, size, count, totalPages: Math.ceil(count / size) }
  })
}

2. 资源详情接口(自动增加访问量)

// GET /api/v1/client/resource/detail?id=xxx
async detail(ctx) {
  const { id } = ctx.query
  if (!id) return global.Response.error(ctx, '缺少资源id')

  const data = await DB.resource.findOne({
    where: { id, status: 1 },
    include: [
      { model: DB.resource_category, as: 'category' },
      { model: DB.resource_detail, as: 'detail' }
    ]
  })

  if (!data) return global.Response.error(ctx, '资源不存在或已下架')

  // 访问量 +1(异步执行,不阻塞返回)
  DB.resource.increment({ view_count: 1 }, { where: { id } }).catch(() => {})

  global.Response.success(ctx, undefined, data)
}

3. 资源配置接口

// GET /api/v1/client/resource/config
async getConfig(ctx) {
  let config = await DB.resource_config.findOne({ where: { id: 1 } })
  if (!config) {
    config = { id: 1, enable_list: 1, description: '' }
  }
  global.Response.success(ctx, undefined, config)
}
管理端接口(需要 JWT 认证)

1. 创建资源

// POST /api/v1/admin/resource/create
async createResource(ctx) {
  const { title, category_id, summary, cover_img, content_img,
          size, price, is_top, create_time, content, password } = ctx.request.body

  // 生成随机 12 位 ID
  const id = genResourceId()

  // 创建主表记录
  const resource = await DB.resource.create({
    id, title, category_id, summary, cover_img, content_img,
    size, price, is_top: is_top ? 1 : 0, create_time, status: 1
  })

  // 创建详情表记录
  if (content || password) {
    await DB.resource_detail.create({
      resource_id: id, content, password
    })
  }

  global.Response.success(ctx, '创建成功', resource)
}

2. 更新资源配置

// PUT /api/v1/admin/resource/config/update
async updateConfig(ctx) {
  const { enable_list, description } = ctx.request.body

  let config = await DB.resource_config.findOne({ where: { id: 1 } })

  if (config) {
    await DB.resource_config.update(
      { enable_list, description, update_time: new Date() },
      { where: { id: 1 } }
    )
  } else {
    await DB.resource_config.create({
      id: 1, enable_list, description, update_time: new Date()
    })
  }

  global.Response.success(ctx, '更新成功')
}

3.4 技术亮点

  1. 随机 ID 生成:避免暴露资源数量,提升安全性
  2. 异步访问量更新:使用 increment + catch,不阻塞接口返回
  3. 参数校验:严格的参数边界检查,防止 SQL 注入和异常查询
  4. 关联查询优化:使用 Sequelize include 一次性获取关联数据,减少数据库查询次数

四、管理后台(Admin)

4.1 技术栈

  • 框架:Vue 2.x
  • UI 组件库:Element UI
  • Markdown 编辑器:mavon-editor
  • HTTP 请求:axios

4.2 核心页面

1. 资源列表页(Resource.vue)

功能特性

  • 资源列表展示(标题、分类、状态、创建时间)
  • 搜索功能(按标题、分类筛选)
  • 分页展示
  • 编辑/删除操作

关键代码

<template>
  <div class="resource">
    <SearchCard
      @search="searchResources"
      @add="$router.push('/resource/create')"
      :options="categoryList"
      tip="资源"
      select="分类"
      needAdd="true"
    />

    <el-card class="app-container">
      <el-table :data="list" border fit highlight-current-row>
        <el-table-column label="标题" prop="title" />
        <el-table-column label="分类" prop="category.name" />
        <el-table-column label="状态" prop="status">
          <template slot-scope="scope">
            <el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
              {{ scope.row.status === 1 ? "上架" : "下架" }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" align="center">
          <template slot-scope="scope">
            <el-button
              size="mini"
              type="primary"
              @click="handleEdit(scope.row.id)"
            >
              编辑
            </el-button>
            <el-button
              size="mini"
              type="danger"
              @click="handleDelete(scope.row.id)"
            >
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>

      <div class="pagination">
        <el-pagination
          background
          :current-page.sync="page"
          layout="total, prev, pager, next"
          :total="count"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>
2. 资源创建/编辑页(ResourceCreate.vue / ResourceEdit.vue)

功能特性

  • 表单验证
  • 富文本编辑器(Markdown)
  • 图片 URL 输入(支持文件云路径)
  • 分类选择
  • 置顶/状态设置

关键代码

<template>
  <el-card class="resource-form">
    <el-form ref="form" :model="form" :rules="rules" label-width="110px">
      <el-form-item label="标题" prop="title">
        <el-input v-model="form.title" />
      </el-form-item>

      <el-form-item label="分类" prop="category_id">
        <el-select v-model="form.category_id" placeholder="请选择分类">
          <el-option
            v-for="item in categoryOptions"
            :key="item.id"
            :label="item.name"
            :value="item.id"
          />
        </el-select>
      </el-form-item>

      <el-form-item label="提取信息">
        <mavon-editor
          ref="md"
          v-model="form.content"
          code-style="atom-one-dark"
          :toolbarsFlag="true"
          :subfield="true"
          style="min-height: 360px;"
        />
      </el-form-item>

      <el-form-item label="提取码">
        <el-input v-model="form.password" placeholder="多个链接用 || 分隔" />
      </el-form-item>

      <el-form-item>
        <el-button @click="reset">重置</el-button>
        <el-button type="primary" @click="submit">立即创建</el-button>
      </el-form-item>
    </el-form>
  </el-card>
</template>

4.3 API 封装

// admin/src/api/resource.js
import http from "@/utils/http";

export default {
  // 资源列表
  httpResourceList(params) {
    return http.get("/resource/list", { params });
  },

  // 资源详情
  httpResourceDetail(id) {
    return http.get("/resource/detail", { params: { id } });
  },

  // 新增资源
  httpResourceCreate(data) {
    return http.post("/resource/create", data);
  },

  // 更新资源
  httpResourceUpdate(data) {
    return http.put("/resource/update", data);
  },

  // 删除资源
  httpResourceDelete(id) {
    return http.delete("/resource/delete", { params: { id } });
  },

  // 分类列表
  httpResourceCategoryList() {
    return http.get("/resource/category/list");
  },

  // 资源配置
  httpResourceConfig() {
    return http.get("/resource/config");
  },

  httpResourceConfigUpdate(data) {
    return http.put("/resource/config/update", data);
  },
};

五、多端客户端(Resource Mini)

5.1 技术栈

  • 框架:uni-app(Vue 2)
  • UI 组件库:uView UI 1.8.4
  • Markdown 渲染:markdown-it
  • HTTP 请求:uni.request(封装)

5.2 项目结构

resource_mini/
├── src/
│   ├── api/
│   │   └── common.js           # API 接口封装
│   ├── pages/
│   │   ├── home/
│   │   │   └── index.vue       # 首页(资源列表)
│   │   └── detail/
│   │       └── index.vue       # 详情页
│   ├── utils/
│   │   ├── request.js          # 请求封装
│   │   └── define.js           # 全局配置
│   └── uview-ui/                # uView UI 组件库
├── pages.json                   # 页面配置
└── vue.config.js                # Vue CLI 配置

5.3 核心页面实现

1. 首页(资源列表)

功能特性

  • 顶部导航(支持安全区域适配)
  • 搜索栏(关键字搜索)
  • 分类侧边栏(抽屉式)
  • 资源列表(卡片式展示)
  • 分页组件
  • 空状态展示
  • 资源配置控制(可关闭列表)

关键代码

<template>
  <view class="page" :class="{ 'no-scroll': showCategory }">
    <!-- 安全区域占位 -->
    <view
      class="safe-area-top"
      :style="{ height: statusBarHeight + 'px' }"
    ></view>

    <!-- 顶部导航 -->
    <view class="nav" v-if="!loading && enableList">
      <view class="logo">
        <text class="gradient-text-multi">资源宝库</text>
      </view>
    </view>

    <!-- 搜索栏 -->
    <view class="search-bar" v-if="!loading && enableList">
      <u-search
        v-model="keyword"
        @search="handleSearch"
        @custom="handleSearch"
        @clear="handleSearch"
      />
      <view class="nav-actions">
        <view class="menu-icon" @click="showCategoryMenu">
          <u-icon name="list-dot" size="40" color="#666"></u-icon>
        </view>
      </view>
    </view>

    <!-- 资源列表 -->
    <view class="list" v-if="enableList && !loading && list.length > 0">
      <view
        class="item"
        v-for="(item, index) in list"
        :key="item.id || index"
        @click="goDetail(item.id)"
      >
        <view class="info">
          <view class="title">{{ item.title }}</view>
          <view class="meta">
            <text v-if="item.is_top" class="badge top">置顶</text>
            <text class="badge cate">
              {{ (item.category && item.category.name) || "资源" }}
            </text>
            <text class="time">{{ item.create_time }}</text>
          </view>
        </view>
        <image
          class="cover"
          :src="coverUrl(item.cover_img)"
          mode="aspectFill"
        />
      </view>
    </view>

    <!-- 空状态 -->
    <view
      class="empty-state"
      v-if="enableList && !loading && list.length === 0"
    >
      <u-icon name="file-text" size="120" color="#d0d0d0"></u-icon>
      <text class="empty-text">暂无资源数据</text>
    </view>

    <!-- 列表关闭提示 -->
    <view class="empty-state" v-if="!loading && !enableList">
      <u-icon name="lock" size="120" color="#d0d0d0"></u-icon>
      <text class="empty-text">资源列表已关闭</text>
      <text class="empty-tip" v-if="config && config.description">
        {{ config.description }}
      </text>
    </view>
  </view>
</template>

<script>
import {
  getResourceList,
  getResourceCategoryList,
  getResourceConfig,
} from "../../api/common.js";

export default {
  data() {
    return {
      list: [],
      categoryList: [],
      showCategory: false,
      currentCategoryId: null,
      keyword: "",
      pagination: { page: 1, size: 10, count: 0, totalPages: 0 },
      loading: false,
      statusBarHeight: 0,
      config: null,
      enableList: true,
    };
  },
  created() {
    // 获取系统信息,设置状态栏高度
    try {
      const systemInfo = uni.getSystemInfoSync();
      this.statusBarHeight = systemInfo.statusBarHeight || 0;
    } catch (e) {
      this.statusBarHeight = 0;
    }
    // 先加载配置,再根据配置决定是否加载列表
    this.loadConfig();
    this.loadCategoryList();
  },
  methods: {
    // 加载资源配置
    async loadConfig() {
      try {
        const res = await getResourceConfig();
        if (res.code === 200 && res.data) {
          this.config = res.data;
          this.enableList = res.data.enable_list === 1;
          if (this.enableList) {
            this.loadList();
          }
        } else {
          this.enableList = true;
          this.loadList();
        }
      } catch (e) {
        console.error("加载资源配置失败", e);
        this.enableList = true;
        this.loadList();
      }
    },
    // 加载资源列表
    async loadList() {
      if (!this.enableList) return;
      if (this.loading) return;
      this.loading = true;
      try {
        const params = {
          page: this.pagination.page,
          size: this.pagination.size,
        };
        if (this.currentCategoryId) {
          params.categoryId = this.currentCategoryId;
        }
        if (this.keyword) {
          params.key = this.keyword;
        }
        const res = await getResourceList(params);
        if (res.code === 200) {
          this.list = res.data?.rows || [];
          this.pagination.count = res.data?.meta?.count || 0;
          this.pagination.totalPages = res.data?.meta?.count
            ? Math.ceil(res.data.meta.count / res.data.meta.size)
            : 0;
        }
      } catch (e) {
        console.error("加载资源列表失败", e);
        uni.showToast({ title: "加载失败,请重试", icon: "none" });
      } finally {
        this.loading = false;
      }
    },
  },
};
</script>
2. 详情页

功能特性

  • 资源详情展示(标题、分类、元信息)
  • Markdown 内容渲染(H5 使用 v-html,小程序使用 rich-text)
  • 资源链接展示(支持多个链接,用 || 分隔)
  • 一键复制链接
  • 自动增加访问量

关键代码

<template>
  <view class="page">
    <!-- 安全区域占位 -->
    <view
      class="safe-area-top"
      :style="{ height: statusBarHeight + 'px' }"
    ></view>

    <!-- 详情内容 -->
    <view class="content" v-if="detail">
      <view class="header">
        <view class="title">{{ detail.title }}</view>
        <view class="meta">
          <text class="badge cate">
            {{ detail.category ? detail.category.name : "资源" }}
          </text>
          <text class="time">{{ detail.create_time }}</text>
        </view>
      </view>

      <!-- 封面图 -->
      <image
        v-if="detail.cover_img"
        class="cover-img"
        :src="coverUrl(detail.cover_img)"
        mode="aspectFill"
      />

      <!-- 详情内容(富文本) -->
      <view
        class="detail-content"
        v-if="detail.detail && detail.detail.content"
      >
        <view class="section-title">资源详情</view>
        <!-- H5 平台使用 v-html -->
        <!-- #ifdef H5 -->
        <div
          class="markdown-body"
          v-html="formatContent(detail.detail.content)"
        ></div>
        <!-- #endif -->
        <!-- 小程序平台使用 rich-text -->
        <!-- #ifndef H5 -->
        <rich-text
          class="markdown-body"
          :nodes="formatContent(detail.detail.content)"
        ></rich-text>
        <!-- #endif -->
      </view>

      <!-- 资源链接 -->
      <view class="password-section" v-if="resourceLinks.length > 0">
        <view class="section-title">资源链接</view>
        <view
          class="password-box"
          v-for="(link, index) in resourceLinks"
          :key="index"
        >
          <text class="password-text">{{ link }}</text>
          <view class="copy-btn" @click="copyLink(link)">复制</view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { getResourceDetail } from "../../api/common.js";
import MarkdownIt from "markdown-it";

export default {
  data() {
    return {
      id: "",
      detail: null,
      loading: false,
      md: null,
      statusBarHeight: 0,
    };
  },
  created() {
    // 初始化 markdown-it
    this.md = new MarkdownIt({
      html: true,
      linkify: true,
      typographer: true,
    });
    // 获取系统信息
    try {
      const systemInfo = uni.getSystemInfoSync();
      this.statusBarHeight = systemInfo.statusBarHeight || 0;
    } catch (e) {
      this.statusBarHeight = 0;
    }
  },
  onLoad(options) {
    this.id = options.id;
    if (!this.id) {
      uni.showToast({ title: "缺少资源ID", icon: "none" });
      setTimeout(() => this.goBack(), 1500);
      return;
    }
    this.loadDetail();
  },
  computed: {
    // 将资源链接按 || 分割成数组
    resourceLinks() {
      if (!this.detail || !this.detail.detail || !this.detail.detail.password) {
        return [];
      }
      const links = this.detail.detail.password.split("||");
      return links.map((link) => link.trim()).filter((link) => link.length > 0);
    },
  },
  methods: {
    async loadDetail() {
      this.loading = true;
      try {
        const res = await getResourceDetail(this.id);
        if (res.code === 200) {
          this.detail = res.data;
        }
      } catch (e) {
        console.error("加载资源详情失败", e);
        uni.showToast({ title: "加载失败,请重试", icon: "none" });
      } finally {
        this.loading = false;
      }
    },
    formatContent(content) {
      if (!content) return "";
      try {
        let html = this.md.render(content);
        // 处理图片路径
        html = html.replace(/<img\s+src="([^"]+)"/g, (match, src) => {
          if (src && !src.startsWith("http")) {
            return `<img src="https://zhouqm.oss-cn-beijing.aliyuncs.com/resource/${src}" style="max-width: 100%; width: 100%; height: auto; display: block; box-sizing: border-box;"`;
          }
          return match;
        });
        // H5 平台直接返回 HTML
        // #ifdef H5
        return html;
        // #endif
        // 小程序平台返回 HTML 字符串(rich-text 会自动解析)
        return html;
      } catch (e) {
        console.error("Markdown 解析失败", e);
        return content.replace(/\n/g, "<br>");
      }
    },
    copyLink(link) {
      if (!link) return;
      uni.setClipboardData({
        data: link,
        success: () => {
          uni.showToast({ title: "已复制到剪贴板", icon: "success" });
        },
      });
    },
  },
};
</script>

5.4 多端适配

1. 条件编译
// #ifdef H5
// H5 平台特有代码
window.open(url, "_blank");
// #endif

// #ifdef MP-WEIXIN
// 微信小程序特有代码
uni.navigateTo({ url: `/pages/webview/webview?url=${url}` });
// #endif

// #ifdef APP-PLUS
// App 平台特有代码
plus.runtime.openURL(url);
// #endif
2. 安全区域适配
// 获取系统信息,设置状态栏高度
try {
  const systemInfo = uni.getSystemInfoSync();
  this.statusBarHeight = systemInfo.statusBarHeight || 0;
} catch (e) {
  this.statusBarHeight = 0;
}
<!-- 安全区域占位 -->
<view class="safe-area-top" :style="{ height: statusBarHeight + 'px' }"></view>
3. 部署配置

H5 部署到子路径

// vue.config.js
module.exports = {
  publicPath: process.env.NODE_ENV === "production" ? "/resource_mini/" : "/",
  devServer: {
    proxy: {
      "/blogapi": {
        target: "http://xx.xx.xx.xxx:xxxx",
        changeOrigin: true,
        pathRewrite: { "^/blogapi": "" },
      },
    },
  },
};
// pages.json
{
  "h5": {
    "router": {
      "mode": "history",
      "base": "/resource_mini/"
    }
  }
}

六、核心功能实现

6.1 资源 ID 随机生成

设计目的:避免使用自增 ID,防止暴露资源数量,提升安全性。

实现方式

// server/app/controllers_admin/resource.js
function genResourceId() {
  const chars =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  let result = "";
  for (let i = 0; i < 12; i++) {
    result += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return result;
}

6.2 访问量自动统计

实现方式:在详情接口中异步更新访问量,不阻塞接口返回。

// 访问量 +1(异步执行,忽略错误)
DB.resource.increment({ view_count: 1 }, { where: { id } }).catch(() => {});

6.3 资源配置控制

功能:支持动态开启/关闭资源列表,显示自定义说明文字。

实现

  • 后端:resource_config 表存储配置
  • 前端:首页加载时先获取配置,根据 enable_list 决定是否显示列表

6.4 Markdown 渲染多端适配

H5 平台:使用 v-html 直接渲染 HTML
小程序平台:使用 rich-text 组件渲染 HTML 字符串

<!-- H5 平台 -->
<!-- #ifdef H5 -->
<div class="markdown-body" v-html="formatContent(content)"></div>
<!-- #endif -->

<!-- 小程序平台 -->
<!-- #ifndef H5 -->
<rich-text class="markdown-body" :nodes="formatContent(content)"></rich-text>
<!-- #endif -->

6.5 分类侧边栏(抽屉式)

实现要点

  • 使用 position: fixed + transform: translateX() 实现侧滑
  • 遮罩层阻止背景滚动
  • 记录滚动位置,关闭时恢复
<view class="page" :class="{ 'no-scroll': showCategory }">
  <!-- 遮罩 -->
  <view
    class="category-mask"
    v-if="showCategory"
    @click="closeCategoryMenu"
    @touchmove.prevent
  ></view>

  <!-- 侧边栏 -->
  <view class="category-sidebar" :class="{ active: showCategory }">
    <!-- 分类列表 -->
  </view>
</view>
.page.no-scroll {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  overflow: hidden;
}

.category-sidebar {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: 60%;
  max-width: 500rpx;
  background: #fff;
  z-index: 999;
  transform: translateX(100%);
  transition: transform 0.3s ease;

  &.active {
    transform: translateX(0);
  }
}

七、技术亮点与难点

7.1 技术亮点

  1. 一套代码多端运行:使用 uni-app 实现 H5/小程序/App 三端统一,大幅降低开发成本
  2. 随机 ID 设计:提升安全性,避免暴露资源数量
  3. 表分离设计:主表 + 详情表分离,提升查询性能
  4. 异步访问量更新:不阻塞接口返回,提升用户体验
  5. 配置化控制:支持动态开启/关闭资源列表,灵活管理
  6. 安全区域适配:完美适配不同设备的顶部安全区域
  7. Markdown 多端渲染:H5 和小程序使用不同渲染方式,保证兼容性

7.2 技术难点与解决方案

难点 1:Node.js v24 兼容性问题

问题buffer-equal-constant-time 包在 Node.js v24 中访问 SlowBuffer 报错。

解决方案

// server/slowbuffer-polyfill.js
(() => {
  const buf = require("buffer");
  global.SlowBuffer = global.SlowBuffer || buf.SlowBuffer || Buffer;
})();
// package.json
{
  "scripts": {
    "start": "node -r ./slowbuffer-polyfill.js bin/www",
    "dev": "nodemon --require ./slowbuffer-polyfill.js bin/www"
  }
}
难点 2:数据库外键约束兼容性

问题:将资源 ID 从自增数字改为随机字符串后,外键约束类型不匹配。

解决方案

-- 1. 删除旧外键
ALTER TABLE `resource_detail` DROP FOREIGN KEY `fk_detail_resource`;

-- 2. 修改字段类型
ALTER TABLE `resource_detail`
  MODIFY `resource_id` VARCHAR(12) NOT NULL;

-- 3. 重新添加外键
ALTER TABLE `resource_detail`
  ADD CONSTRAINT `fk_detail_resource`
  FOREIGN KEY (`resource_id`)
  REFERENCES `resource` (`id`)
  ON DELETE CASCADE;

八、总结与展望

8.1 项目总结

本项目实现了一个完整的资源管理系统,涵盖:

  • ✅ 后端 API:RESTful 接口,支持资源 CRUD、分类管理、配置管理
  • ✅ 管理后台:Vue + Element UI,提供便捷的资源管理界面
  • ✅ 多端客户端:uni-app 实现 H5/小程序/App 三端统一
  • ✅ 数据库设计:合理的表结构设计,支持外键约束和索引优化
  • ✅ 用户体验:搜索、分类筛选、分页、空状态、配置控制等完善功能

8.2 技术栈总结

层级技术栈说明
后端Node.js + Koa + Sequelize + MySQL轻量级、高效、ORM 操作
管理后台Vue 3 + Element UI + mavon-editor成熟稳定、组件丰富
客户端uni-app + uView UI + markdown-it一套代码多端运行

8.3 未来优化方向

  1. 性能优化

    • 添加 Redis 缓存,减少数据库查询
    • 图片 CDN 加速
    • 列表虚拟滚动(大数据量场景)
  2. 功能扩展

    • 资源收藏功能
    • 用户评论系统
    • 资源推荐算法
    • 搜索历史记录
  3. 体验优化

    • 骨架屏加载
    • 下拉刷新优化
    • 图片懒加载
    • PWA 支持(H5)
  4. 安全加固

    • API 限流
    • 防爬虫机制
    • 内容审核

附录:相关资源

API 接口文档

客户端接口(无需认证):

  • GET /api/v1/client/resource/list - 资源列表
  • GET /api/v1/client/resource/detail?id=xxx - 资源详情
  • GET /api/v1/client/resource/category/list - 分类列表
  • GET /api/v1/client/resource/config - 获取配置

管理端接口(需要 JWT 认证):

  • GET /api/v1/admin/resource/list - 资源列表
  • POST /api/v1/admin/resource/create - 创建资源
  • PUT /api/v1/admin/resource/update - 更新资源
  • DELETE /api/v1/admin/resource/delete - 删除资源
  • GET /api/v1/admin/resource/config - 获取配置
  • PUT /api/v1/admin/resource/config/update - 更新配置

项目地址

  • 后端服务server/ 目录
  • 管理后台admin/ 目录
  • 多端客户端resource_mini/ 目录

扫码_搜索联合传播样式-标准色版.png

💡 提示:本文档基于实际项目经验总结,如有问题欢迎交流讨论。如果对你有帮助,欢迎点赞、收藏、转发!