开发注意事项
项目架构理解
技术栈
- 前端: 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
实现列表功能(分页、排序、自定义列) - 使用
ZyInput
、ZySelect
、ZyToggle
等组件构建表单 - 统一的错误处理和用户反馈(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
事件
❌ 后端只支持单一数据格式
❌ 缺少类型定义和验证
❌ 不使用项目的组件库和工具函数
❌ 忽略移动端适配
❌ 不添加国际化支持
❌ 不处理异步数据加载状态
❌ 缺少错误处理和用户反馈
开发流程总结
- 定义数据模型 - 在
models/
目录下创建完整的类型定义 - 创建API接口 - 在
utils/api/
目录下定义前端API,支持类型约束 - 实现后端API - 在
server/api/
目录下实现完整的CRUD操作 - 开发前端页面 - 使用项目组件库,实现列表和表单页面
- 配置路由 - 添加路由配置,支持统一模板
- 添加国际化 - 为所有路由名称添加翻译
- 测试和调试 - 确保数据流和用户体验
模块开发检查清单
- 数据模型定义完整(包含关联数据)
- API接口类型约束完整
- 后端API实现完整(增删改查)
- 前端列表页面功能完整(分页、排序、删除)
- 前端表单页面功能完整(添加、编辑、验证)
- 路由配置正确
- 国际化翻译完整
- 移动端适配
- 错误处理完善
- 用户体验优化
这个总结涵盖了项目的核心开发模式和最佳实践,可以作为后续模块开发的参考模板。通过遵循这个流程,可以确保新模块与现有功能保持一致的质量和用户体验。