开发注意事项

项目架构理解

技术栈

  • 前端: Nuxt 3 + Vue 3 + TypeScript
  • 后端: Nuxt 3 Server API + MySQL
  • UI组件: 自定义组件库(ZyInput、ZyTextarea、ZySelect、ZyDataTable等)
  • 状态管理: Pinia
  • 样式: Tailwind CSS + SCSS
  • 国际化: Vue I18n

项目结构特点

bash 复制代码
app/
├── components/common/          # 通用组件
├── pages/admin/               # 管理后台页面
├── utils/api/                 # API接口定义
├── models/                    # 数据模型定义
├── stores/                    # 状态管理
└── i18n/locales/             # 国际化文件

server/
├── api/                       # 后端API接口
└── utils/                     # 后端工具函数

完整的开发流程(基于四个模块经验总结)

第一步:数据模型定义 ✅

typescript 复制代码
// models/video.ts (以视频管理为例)
export class VideoAndCollection {
  video_id: number = 0;
  video_coll_id: number = 1;
  video_text: string = "";
  video_cover: string = "";
  video_path: string = "";
  video_date: string = "";
  video_views: number = 0;
  video_likes: number = 0;
  video_comments: number = 0;
  video_private: number = 0;
  video_password: string = "";
  has_password: boolean = false;

  // 关联的分类信息
  vi_coll_id: number = 1;
  vi_coll_path: string = "";
  vi_coll_title: string = "";
  vi_coll_cover: string = "";
}

// 列表数据类型
export class VideoAndCollectionList {
  list: VideoAndCollection[];
  total: number = 0;
  constructor() {
    this.list = [new VideoAndCollection()];
  }
}

// API接口类型定义
export interface ApiAdd {
  params: {
    video_coll_id: number;
    video_text: string;
    video_cover: string;
    video_path: string;
    video_date: string;
    video_private: number;
    video_password: string;
  };
  result: ResOptions<null>;
}

关键要点

  • 创建包含关联数据的完整模型类
  • 定义列表数据类型(包含分页信息)
  • 为每个API操作定义完整的类型接口
  • 使用默认值初始化模型实例

第二步:API接口定义 ✅

typescript 复制代码
// utils/api/videos.ts
import { video } from "@@/models";
type ApiIndexModelType = video.ApiIndex;
type ApiAddModelType = video.ApiAdd;
type ApiUpdateModelType = video.ApiUpdate;
type ApiDeleteModelType = video.ApiDelete;

export const ApiVideo = {
  // 查询列表(支持分页和排序)
  getVideosList(
    params: () => ApiIndexModelType["params"] | null,
    ApiServiceOptions?: ApiServiceOptions,
  ): Promise<AsyncData<ApiIndexModelType["result"]>> {
    return ApiService.post("/videos/index", params, ApiServiceOptions);
  },

  // 查询单条记录
  showVideo(
    params: () => ApiShowModelType["params"] | null,
    ApiServiceOptions?: ApiServiceOptions,
  ): Promise<AsyncData<ApiShowModelType["result"]>> {
    return ApiService.post(`/videos/show`, params, ApiServiceOptions);
  },

  // 添加记录
  addVideo(
    params: ApiAddModelType["params"],
  ): Promise<AsyncData<ApiAddModelType["result"]>> {
    return ApiService.post("/videos/add", params);
  },

  // 更新记录
  updateVideo(
    params: ApiUpdateModelType["params"],
  ): Promise<AsyncData<ApiUpdateModelType["result"]>> {
    return ApiService.post("/videos/update", params);
  },

  // 删除记录
  deleteVideo(
    params: ApiDeleteModelType["params"],
  ): Promise<AsyncData<ApiDeleteModelType["result"]>> {
    return ApiService.post("/videos/delete", params);
  },

  // 获取分类列表
  getVideoCollectionList(): Promise<AsyncData<ApiCollectionIndexModelType["result"]>> {
    return ApiService.post("/videos/collection");
  },
};

关键要点

  • 使用函数返回参数,支持动态参数获取
  • 为每个API方法定义完整的类型约束
  • 支持可选的ApiServiceOptions参数
  • 统一的错误处理和响应格式

第三步:后端API实现 ✅

3.1 列表API(支持管理功能)

typescript 复制代码
// server/api/videos/index/index.post.ts
export default defineEventHandler(async (event) => {
  const body = (await readBody(event)) as ApiIndexModelType["params"];
  const pageNumer = Number(body.page_numer || -1);
  const pageSize = Number(body.page_size || -1);
  const collectionPath = body.vi_coll_path || null;

  // 验证 JWT 令牌
  const isLoggedIn = loginVerify(event);

  // 构建SQL查询
  let sql = "SELECT * FROM videos JOIN video_collections ON videos.video_coll_id = video_collections.vi_coll_id";
  let whereConditions = [];
  let values = [];

  // 如果未登录,只显示公开内容
  if (!isLoggedIn) {
    whereConditions.push("videos.video_private != 1");
  }

  // 添加分类过滤
  if (collectionPath) {
    whereConditions.push("video_collections.vi_coll_path = ?");
    values.push(collectionPath);
  }

  if (whereConditions.length > 0) {
    sql += " WHERE " + whereConditions.join(" AND ");
  }

  sql += " ORDER BY videos.video_date DESC";

  // 添加分页
  if (pageNumer !== -1 && pageSize !== -1) {
    const pageLimit = pageNumer == 1 ? 0 : (pageNumer - 1) * pageSize;
    sql += " LIMIT ?, ?";
    values.push(pageLimit, pageSize);
  }

  const dbResults = await getHandledQuery(sql, values);

  if (dbResults.code === 0 && dbResults.data && dbResults.data.length > 0) {
    // 处理数据(如截断长文本)
    dbResults.data.forEach((item: VideoAndCollection) => {
      if (item.video_text.length > 60) {
        item.video_text = item.video_text.substring(0, 60) + "...";
      }
    });

    // 如果未登录,过滤密码内容
    if (!isLoggedIn) {
      dbResults = videoPasswordFilter(dbResults, null, isLoggedIn);
    }

    return setJson(
      { data: { list: dbResults.data, total: dbResults.data.length } },
      dbResults,
    );
  }

  return dbResults;
});

3.2 添加API

typescript 复制代码
// server/api/videos/add/index.post.ts
export default defineEventHandler(async (event) => {
  // 验证 JWT 令牌
  const isLoggedIn = loginVerify(event);
  if (!isLoggedIn) {
    return setJson(
      { data: null },
      { code: 401, message: "未登录", data: null },
    );
  }

  const body = (await readBody(event)) as ApiAddModelType["params"];
  const { video_coll_id, video_text, video_cover, video_path, video_date, video_private, video_password } = body;

  // 验证必填字段
  if (!video_text?.trim()) {
    return setJson(
      { data: null },
      { code: 400, message: "视频标题不能为空", data: null },
    );
  }

  if (!video_path?.trim()) {
    return setJson(
      { data: null },
      { code: 400, message: "视频路径不能为空", data: null },
    );
  }

  if (!video_date) {
    return setJson(
      { data: null },
      { code: 400, message: "发布日期不能为空", data: null },
    );
  }

  const sql = "INSERT INTO videos (video_coll_id, video_text, video_cover, video_path, video_date, video_private, video_password) VALUES (?, ?, ?, ?, ?, ?, ?)";
  const values = [
    video_coll_id || 1,
    video_text.trim(),
    video_cover || "",
    video_path.trim(),
    video_date,
    video_private || 0,
    video_password || "",
  ];

  const dbResults = await getHandledQuery(sql, values);

  if (dbResults.code === 0) {
    return setJson(
      { data: null },
      { code: 0, message: "添加成功", data: null },
    );
  } else {
    return setJson(
      { data: null },
      { code: 500, message: "添加失败", data: null },
    );
  }
});

3.3 更新API

typescript 复制代码
// server/api/videos/update/index.post.ts
export default defineEventHandler(async (event) => {
  // 验证 JWT 令牌
  const isLoggedIn = loginVerify(event);
  if (!isLoggedIn) {
    return setJson(
      { data: null },
      { code: 401, message: "未登录", data: null },
    );
  }

  const body = (await readBody(event)) as ApiUpdateModelType["params"];
  const { video_id, video_coll_id, video_text, video_cover, video_path, video_date, video_private, video_password } = body;

  // 验证必填字段
  if (!video_id) {
    return setJson(
      { data: null },
      { code: 400, message: "视频ID不能为空", data: null },
    );
  }

  // ... 其他验证

  const sql = "UPDATE videos SET video_coll_id = ?, video_text = ?, video_cover = ?, video_path = ?, video_date = ?, video_private = ?, video_password = ? WHERE video_id = ?";
  const values = [
    video_coll_id || 1,
    video_text.trim(),
    video_cover || "",
    video_path.trim(),
    video_date,
    video_private || 0,
    video_password || "",
    video_id,
  ];

  const dbResults = await getHandledQuery(sql, values);

  if (dbResults.code === 0) {
    return setJson(
      { data: null },
      { code: 0, message: "更新成功", data: null },
    );
  } else {
    return setJson(
      { data: null },
      { code: 500, message: "更新失败", data: null },
    );
  }
});

3.4 删除API

typescript 复制代码
// server/api/videos/delete/index.post.ts
export default defineEventHandler(async (event) => {
  // 验证 JWT 令牌
  const isLoggedIn = loginVerify(event);
  if (!isLoggedIn) {
    return setJson(
      { data: null },
      { code: 401, message: "未登录", data: null },
    );
  }

  const body = (await readBody(event)) as ApiDeleteModelType["params"];
  const { video_id } = body;

  // 验证必填字段
  if (!video_id) {
    return setJson(
      { data: null },
      { code: 400, message: "视频ID不能为空", data: null },
    );
  }

  const sql = "DELETE FROM videos WHERE video_id = ?";
  const values = [video_id];

  const dbResults = await getHandledQuery(sql, values);

  if (dbResults.code === 0) {
    return setJson(
      { data: null },
      { code: 0, message: "删除成功", data: null },
    );
  } else {
    return setJson(
      { data: null },
      { code: 500, message: "删除失败", data: null },
    );
  }
});

3.5 分类列表API

typescript 复制代码
// server/api/videos/collection/index.post.ts
export default defineEventHandler(async (event) => {
  const sql = "SELECT * FROM video_collections ORDER BY vi_coll_id ASC";
  const dbResults = await getHandledQuery(sql, []);

  if (dbResults.code === 0) {
    return setJson(
      { data: { list: dbResults.data } },
      dbResults,
    );
  } else {
    return setJson(
      { data: null },
      { code: 500, message: "查询失败", data: null },
    );
  }
});

关键要点

  • 所有API都需要JWT验证(除了公开的列表和详情)
  • 统一的错误处理和响应格式
  • 完整的参数验证
  • 支持管理功能(显示私有内容)
  • 使用项目提供的工具函数(setJson、getHandledQuery等)

第四步:前端页面开发 ✅

4.1 列表页面

vue 复制代码
<template>
  <CommonMainSection>
    <ClientOnly>
      <Title> {{ $t("menu.video-manage") }} </Title>
    </ClientOnly>
    <div class="p-6 portrait:p-2 portrait:sm:p-4 portrait:lg:p-6 landscape:p-8 bg-level-2 dark:bg-level-1 ring-1 ring-slate-100 dark:ring-slate-800 shadow rounded">
      <div class="p-4 mb-2 flex justify-between items-center">
        <h3 class="font-bold text-lg portrait:text-sm">视频管理</h3>
        <div class="flex gap-2">
          <ZyButton @click="router.push('/admin/post-manage/video/add')">
            <ZyIcon name="i-solar-add-circle-bold-duotone" class="mr-2" size="1.75rem" />
            添加视频
          </ZyButton>
        </div>
      </div>

      <ZyFetchLoading :fetchData="videoListDataLazyFetch" @fetchOnload="showVideoList">
        <template #loading></template>
        <template #onload>
          <ZyDataTable
            :table-data="tableData"
            :table-header="tableHeader"
            :loading="loading"
            :remote="true"
            :current-page="currentPage"
            :rows-per-page="pageSize"
            :total="total"
            :sort-label="sortLabel"
            :sort-order="sortOrder"
            selection
            enableItemsPerPageDropdown
            enableCurrentPageButtons
            @current-change="handlePageChange"
            @items-per-page-change="handlePageSizeChange"
            @sort-change="handleSortChange"
          >
            <!-- 自定义列模板 -->
            <template v-slot:cell-cover="{ row: item }">
              <img v-if="item.video_cover" :src="`${cdnUrl}${item.video_cover}`" :alt="item.video_text" class="w-12 h-12 object-cover rounded" />
              <div v-else class="w-12 h-12 bg-level-3 dark:bg-level-2 rounded flex items-center justify-center">
                <ZyIcon name="i-solar-videocamera-linear" size="1.5rem" />
              </div>
            </template>

            <template v-slot:cell-actions="{ row: item }">
              <div class="flex gap-2 justify-end">
                <ZyLink :to="getJumpRoutes(item)" type="push">
                  <div class="w-10 h-10 p-2.5 bg-level-3 text-text-2 dark:text-text-1 rounded-sm hover:bg-theme-500 dark:hover:bg-theme-100 hover:text-white transition-all">
                    <ZyIcon name="i-solar-pen-bold" size="100%" />
                  </div>
                </ZyLink>
                <div class="w-10 h-10 p-2.5 bg-level-3 text-text-2 dark:text-text-1 rounded-sm hover:bg-red-500 hover:text-white transition-all cursor-pointer" @click="deleteVideo(item.video_id)">
                  <ZyIcon name="i-solar-trash-bin-trash-bold" size="100%" />
                </div>
              </div>
            </template>
          </ZyDataTable>
        </template>
      </ZyFetchLoading>
    </div>
  </CommonMainSection>
</template>

<script setup lang="ts">
import type { VideoAndCollection, VideoAndCollectionList } from "~~/models/video";

const config = useRuntimeConfig();
const cdnUrl = config.public.CDN_URL;
const router = useRouter();

// 数据状态
const tableData = ref<VideoAndCollection[]>([]);
const loading = ref(false);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const sortLabel = ref("");
const sortOrder = ref("");

// 排序计数器状态
const sortCounterFlag = ref<Array<{ columnName: string; counter: number }>>([]);

// 表格头部配置
const tableHeader = computed(() => {
  let arr: TableHeader[] = [
    {
      name: "ID",
      key: "id",
      sortingField: "video_id",
      columnClass: "portrait:hidden",
      sortable: true,
      sortOrder: getSortOrder("video_id"),
    },
    {
      name: "封面",
      key: "cover",
      sortable: false,
      columnClass: "portrait:hidden",
    },
    {
      name: "标题",
      key: "title",
      sortingField: "video_text",
      sortable: true,
      sortOrder: getSortOrder("video_text"),
    },
    {
      name: "分类",
      key: "collection",
      sortingField: "vi_coll_title",
      columnClass: "portrait:hidden",
      sortable: true,
      sortOrder: getSortOrder("vi_coll_title"),
    },
    {
      name: "发布日期",
      key: "date",
      sortingField: "video_date",
      columnClass: "portrait:hidden",
      sortable: true,
      sortOrder: getSortOrder("video_date"),
    },
    {
      name: "操作",
      key: "actions",
      sortable: false,
    },
  ];
  return arr;
});

// 获取列的排序状态
const getSortOrder = (columnName: string) => {
  const currentSortItem = sortCounterFlag.value.find(
    (item) => item.columnName === columnName,
  );
  if (!currentSortItem) return "";

  if (currentSortItem.counter === 1) return "asc";
  if (currentSortItem.counter === 2) return "desc";
  return "";
};

// 获取视频列表
const videoListDataLazyFetch = await ApiVideo.getVideosList(() => ({
  page_numer: currentPage.value,
  page_size: pageSize.value,
  vi_coll_path: undefined,
  sort_label: sortLabel.value || undefined,
  sort_order: sortOrder.value || undefined,
}));

// 显示视频列表
const showVideoList = (result: ResOptions<VideoAndCollectionList>) => {
  if (result.data?.list) {
    tableData.value = result.data.list;
    total.value = result.data.total;
  }
};

// 处理分页变化
const handlePageChange = (page: number) => {
  currentPage.value = page;
  refreshVideoList();
};

// 处理每页数量变化
const handlePageSizeChange = (size: number) => {
  pageSize.value = size;
  currentPage.value = 1; // 重置到第一页
  refreshVideoList();
};

// 处理排序变化
const handleSortChange = (sortInfo: { columnName: string; order: string }) => {
  const columnName = sortInfo.columnName;

  // 更新排序计数器
  const existingIndex = sortCounterFlag.value.findIndex(
    (item) => item.columnName === columnName,
  );

  if (existingIndex >= 0) {
    sortCounterFlag.value[existingIndex].counter += 1;
  } else {
    sortCounterFlag.value.push({
      columnName: columnName,
      counter: 1,
    });
  }

  // 获取当前计数器值
  const currentItem = sortCounterFlag.value.find(
    (item) => item.columnName === columnName,
  );
  const currentCounter = currentItem?.counter || 1;

  // 根据计数器值确定排序方向
  if (currentCounter === 1) {
    sortLabel.value = columnName;
    sortOrder.value = "asc";
  } else if (currentCounter === 2) {
    sortLabel.value = columnName;
    sortOrder.value = "desc";
  } else if (currentCounter >= 3) {
    sortLabel.value = "";
    sortOrder.value = "";
    sortCounterFlag.value = sortCounterFlag.value.filter(
      (item) => item.columnName !== columnName,
    );
  }

  refreshVideoList();
};

// 刷新视频列表
const refreshVideoList = () => {
  loading.value = true;
  ApiVideo.getVideosList(() => ({
    page_numer: currentPage.value,
    page_size: pageSize.value,
    vi_coll_path: undefined,
    sort_label: sortLabel.value || undefined,
    sort_order: sortOrder.value || undefined,
  }))
    .then((result) => {
      if (result.data.value?.code === 0) {
        showVideoList(result.data.value);
      }
      loading.value = false;
    })
    .catch(() => {
      loading.value = false;
    });
};

// 删除视频
const deleteVideo = async (videoId: number) => {
  if (confirm("确定要删除这个视频吗?")) {
    const { data } = await ApiVideo.deleteVideo({ video_id: videoId });
    if (data.value?.code === 0) {
      window.ZyToast({
        title: "删除成功",
        text: "视频已删除",
      });
      refreshVideoList();
    } else {
      window.ZyToast({
        title: "删除失败",
        text: data.value?.message || "删除失败",
      });
    }
  }
};

// 获取编辑路由
const getJumpRoutes = (item: VideoAndCollection) => {
  const id = item.video_id;
  return `/admin/post-manage/video/${id}/edit`;
};
</script>

4.2 统一模板页面(添加/编辑)

vue 复制代码
<template>
  <CommonMainSection>
    <ClientOnly>
      <Title>
        {{ isEditMode ? `${$t("menu.video-manage")} - 编辑视频` : `${$t("menu.video-manage")} - 添加视频` }}
      </Title>
    </ClientOnly>
    <div class="p-6 portrait:p-2 portrait:sm:p-4 portrait:lg:p-6 landscape:p-8 bg-level-2 dark:bg-level-1 ring-1 ring-slate-100 dark:ring-slate-800 shadow rounded">
      <div class="p-4 mb-2 flex justify-between items-center">
        <h3 class="font-bold text-lg portrait:text-sm">
          {{ isEditMode ? "编辑视频" : "添加视频" }}
        </h3>
        <div>
          <ZyButton @click="router.push('/admin/post-manage/video')">
            <ZyIcon name="i-solar-arrow-left-bold" class="mr-2" size="1.75rem" />
            返回列表
          </ZyButton>
        </div>
      </div>

      <ZyFetchLoading :fetchData="videoDataLazyFetch" @fetchOnload="showVideo">
        <template #loading></template>
        <template #onload>
          <form @submit.prevent class="flex flex-col gap-4">
            <div class="flex flex-col gap-2 text-indigo-500">
              <label class="text-text-2 text-sm">视频标题</label>
              <ZyInput
                v-model="videoData.video_text"
                icon="i-solar-videocamera-bold-duotone"
                placeholder="请输入视频标题..."
                class="w-full text-text-1"
                color="indigo"
                required
              />
            </div>

            <div class="flex flex-col gap-2 text-orange-500">
              <label class="text-text-2 text-sm">视频路径</label>
              <ZyInput
                v-model="videoData.video_path"
                icon="i-solar-play-circle-bold-duotone"
                placeholder="请输入视频路径..."
                class="w-full text-text-1"
                color="orange"
                required
              />
            </div>

            <div class="flex gap-4 portrait:flex-col">
              <div class="flex-1 flex flex-col gap-2 text-yellow-500">
                <label class="text-text-2 text-sm">发布日期</label>
                <ZyInput
                  v-model="videoData.video_date"
                  icon="i-solar-calendar-date-bold-duotone"
                  type="datetime-local"
                  placeholder="日期"
                  class="w-full text-text-1"
                  color="yellow"
                  required
                />
              </div>
            </div>

            <Toolbar body-background>
              <div class="flex flex-col gap-4">
                <div class="flex flex-col gap-2 text-green-500">
                  <label class="text-text-2 text-sm">所属分类</label>
                  <ZyFetchLoading :fetchData="collectionListDataLazyFetch" @fetchOnload="showCollectionList">
                    <template #loading>
                      <div class="h-10 bg-level-3 dark:bg-level-2 rounded border animate-pulse"></div>
                    </template>
                    <template #onload>
                      <ZySelect
                        v-model="videoData.video_coll_id"
                        :options="collectionListData.list"
                        labelField="vi_coll_title"
                        valueField="vi_coll_id"
                        placeholder="选择分类"
                        class="w-full text-text-1"
                      />
                    </template>
                  </ZyFetchLoading>
                </div>

                <div class="flex flex-col gap-2 text-sky-500">
                  <label class="text-text-2 text-sm">封面图片</label>
                  <ZyInput
                    v-model="videoData.video_cover"
                    icon="i-solar-gallery-minimalistic-bold-duotone"
                    type="text"
                    placeholder="封面图片URL"
                    class="w-full text-text-1"
                    color="sky"
                  />
                </div>

                <div class="flex flex-col gap-2 text-slate-500">
                  <label class="text-text-2 text-sm">密码锁</label>
                  <ZyInput
                    v-model="videoData.video_password"
                    icon="i-solar-password-bold-duotone"
                    type="text"
                    placeholder="密码锁"
                    class="w-full text-text-1"
                    color="slate"
                  />
                </div>

                <div class="flex flex-col gap-2 text-indigo-500">
                  <label class="text-text-2 text-sm">仅自己可见</label>
                  <ZyToggle v-model="videoData.video_private" color="indigo" />
                </div>
              </div>

              <template #footer>
                <div class="flex flex-col gap-4">
                  <ZyButton v-if="isEditMode" class="w-full" @click="updateVideo">
                    <ZyIcon name="i-solar-file-bold-duotone" class="mr-2" size="1.75rem" />
                    <span>保存修改</span>
                  </ZyButton>
                  <ZyButton v-else @click="addVideo" class="w-full">
                    <ZyIcon name="i-solar-upload-bold-duotone" class="mr-2" size="1.75rem" />
                    <span>添加视频</span>
                  </ZyButton>
                </div>
              </template>
            </Toolbar>
          </form>
        </template>
      </ZyFetchLoading>
    </div>
  </CommonMainSection>
</template>

<script setup lang="ts">
import { video } from "@@/models";
import type { VideoAndCollection } from "~~/models/video";

const route = useRoute();
const router = useRouter();
const id = route.params.id as string;

const isEditMode = computed(() => route.fullPath.includes("edit"));

// 表单数据
const videoData = ref<VideoAndCollection>(new video.VideoAndCollection());

// 获取视频内容
let videoDataLazyFetch: AsyncData<ResOptions<VideoAndCollection>> | undefined = undefined;
if (isEditMode.value) {
  videoDataLazyFetch = await ApiVideo.showVideo(() => ({
    video_id: id,
  }));
}

// 获取分类列表
const collectionListDataLazyFetch = await ApiVideo.getVideoCollectionList();
const collectionListData = ref<video.VideoCollectionList>(new video.VideoCollectionList());

const showCollectionList = (result: ResOptions<video.VideoCollectionList>) => {
  collectionListData.value = result.data;
};

const showVideo = (result: ResOptions<VideoAndCollection>) => {
  videoData.value = result.data;

  // 处理日期格式 - 确保是datetime-local格式
  if (videoData.value.video_date) {
    const date = new Date(videoData.value.video_date);
    videoData.value.video_date = date.toISOString().slice(0, 16);
  }
};

const addVideo = async () => {
  // 验证必填字段
  if (!videoData.value.video_text?.trim()) {
    window.ZyToast({
      title: "验证失败",
      text: "请输入视频标题",
    });
    return;
  }

  if (!videoData.value.video_path?.trim()) {
    window.ZyToast({
      title: "验证失败",
      text: "请输入视频路径",
    });
    return;
  }

  if (!videoData.value.video_date) {
    window.ZyToast({
      title: "验证失败",
      text: "请选择发布日期",
    });
    return;
  }

  const { data } = await ApiVideo.addVideo({
    video_coll_id: videoData.value.video_coll_id,
    video_text: videoData.value.video_text.trim(),
    video_cover: videoData.value.video_cover,
    video_path: videoData.value.video_path.trim(),
    video_date: videoData.value.video_date,
    video_private: videoData.value.video_private,
    video_password: videoData.value.video_password,
  });

  if (data.value?.code === 0) {
    window.ZyToast({
      title: "添加成功",
      text: "视频已经添加",
    });
    router.push("/admin/post-manage/video");
  } else {
    window.ZyToast({
      title: "添加失败",
      text: data.value?.message || "添加失败",
    });
  }
};

const updateVideo = async () => {
  // 验证必填字段
  if (!videoData.value.video_text?.trim()) {
    window.ZyToast({
      title: "验证失败",
      text: "请输入视频标题",
    });
    return;
  }

  if (!videoData.value.video_path?.trim()) {
    window.ZyToast({
      title: "验证失败",
      text: "请输入视频路径",
    });
    return;
  }

  if (!videoData.value.video_date) {
    window.ZyToast({
      title: "验证失败",
      text: "请选择发布日期",
    });
    return;
  }

  const { data } = await ApiVideo.updateVideo({
    video_id: videoData.value.video_id,
    video_coll_id: videoData.value.video_coll_id,
    video_text: videoData.value.video_text.trim(),
    video_cover: videoData.value.video_cover,
    video_path: videoData.value.video_path.trim(),
    video_date: videoData.value.video_date,
    video_private: videoData.value.video_private,
    video_password: videoData.value.video_password,
  });

  if (data.value?.code === 0) {
    window.ZyToast({
      title: "保存成功",
      text: "视频已经更新",
    });
    router.push("/admin/post-manage/video");
  } else {
    window.ZyToast({
      title: "保存失败",
      text: data.value?.message || "保存失败",
    });
  }
};

onMounted(() => {
  if (!isEditMode.value) {
    // 初始化默认值
    videoData.value.video_date = new Date().toISOString().slice(0, 16);
  }
});
</script>

关键要点

  • 使用 ZyFetchLoading 处理异步数据加载
  • 通过 @fetchOnload 事件处理数据回调
  • 使用 ZyDataTable 实现列表功能(分页、排序、自定义列)
  • 使用 ZyInputZySelectZyToggle 等组件构建表单
  • 统一的错误处理和用户反馈(ZyToast)
  • 响应式设计和移动端适配

第五步:路由配置 ✅

typescript 复制代码
// app/routers/index.ts 和 app/utils/helpers/router/router.ts
{
  path: "/admin/post-manage/video",
  name: "video-manage",
  component: () => import("@/pages/admin/post-manage/video/index.vue"),
  meta: {
    navigate: true,
    defaultIcon: "i-solar-videocamera-linear",
    activatedIcon: "i-solar-videocamera-bold",
    order: 2.15,
    type: "admin",
  },
},
{
  path: "/admin/post-manage/video/add",
  name: "video-add",
  component: () => import("@/pages/admin/post-manage/video/[id].vue"),
  meta: {
    order: 2.151,
    type: "admin",
  },
},
{
  path: "/admin/post-manage/video/:id/edit",
  name: "video-edit",
  component: () => import("@/pages/admin/post-manage/video/[id].vue"),
  meta: {
    order: 2.152,
    type: "admin",
  },
},

关键要点

  • 列表页面使用 index.vue
  • 添加和编辑页面统一使用 [id].vue 模板
  • 通过路由参数判断编辑模式
  • 配置正确的图标和排序

第六步:国际化支持 ✅

json 复制代码
// app/i18n/locales/zh/menu.json
{
  "video-manage": "视频管理",
  "video-add": "添加视频",
  "video-edit": "编辑视频"
}

// app/i18n/locales/en/menu.json
{
  "video-manage": "Video Manage",
  "video-add": "Add Video",
  "video-edit": "Edit Video"
}

关键要点

  • 为每个路由名称添加对应的翻译
  • 支持中英文切换
  • HeaderTitle组件会自动显示对应的标题

关键要点总结

1. 数据流处理

  • 前端:使用 v-model 双向绑定,支持自定义组件
  • 后端:处理多种数据格式(JSON、form-urlencoded)
  • 数据库:存储JSON字符串,前端解析为数组
  • 验证:前后端双重验证,确保数据完整性

2. 异步数据加载

  • 使用 ZyFetchLoading 组件处理异步数据
  • 通过 @fetchOnload 事件处理数据回调
  • 使用 useAsyncData 进行数据管理
  • 支持加载状态和错误处理

3. 组件设计原则

  • 统一的 v-model 支持
  • 图标和颜色主题支持
  • 错误处理和验证
  • 响应式设计
  • 移动端适配

4. API设计模式

  • 统一的响应格式 ResOptions<T>
  • JWT验证中间件
  • 错误处理和状态码
  • 支持多种数据格式
  • 管理功能和公开功能的区分

5. 类型安全

  • 完整的TypeScript类型定义
  • 模型类定义
  • API接口类型约束
  • 运行时类型检查

6. 用户体验

  • 统一的错误提示(ZyToast)
  • 加载状态指示
  • 确认对话框
  • 表单验证反馈
  • 响应式布局

错误方法(避免)

直接使用原生HTML元素而不封装组件
手动处理 :value@input 事件
后端只支持单一数据格式
缺少类型定义和验证
不使用项目的组件库和工具函数
忽略移动端适配
不添加国际化支持
不处理异步数据加载状态
缺少错误处理和用户反馈

开发流程总结

  1. 定义数据模型 - 在 models/ 目录下创建完整的类型定义
  2. 创建API接口 - 在 utils/api/ 目录下定义前端API,支持类型约束
  3. 实现后端API - 在 server/api/ 目录下实现完整的CRUD操作
  4. 开发前端页面 - 使用项目组件库,实现列表和表单页面
  5. 配置路由 - 添加路由配置,支持统一模板
  6. 添加国际化 - 为所有路由名称添加翻译
  7. 测试和调试 - 确保数据流和用户体验

模块开发检查清单

  • 数据模型定义完整(包含关联数据)
  • API接口类型约束完整
  • 后端API实现完整(增删改查)
  • 前端列表页面功能完整(分页、排序、删除)
  • 前端表单页面功能完整(添加、编辑、验证)
  • 路由配置正确
  • 国际化翻译完整
  • 移动端适配
  • 错误处理完善
  • 用户体验优化

这个总结涵盖了项目的核心开发模式和最佳实践,可以作为后续模块开发的参考模板。通过遵循这个流程,可以确保新模块与现有功能保持一致的质量和用户体验。