gowind-admin-dev

star 503

GoWind Admin (Vue3 + Element Plus) 后台管理脚手架二开指南。帮助二开人员理解脚手架架构约定,按照标准流程创建新业务 CRUD 模块、接入 gRPC API、使用 Pro 组件库构建页面、配置路由与国际化。所有示例均来自脚手架内置模块的真实代码。

tx7do By tx7do schedule Updated 6/6/2026

name: gowind-admin-dev description: GoWind Admin (Vue3 + Element Plus) 后台管理脚手架二开指南。帮助二开人员理解脚手架架构约定,按照标准流程创建新业务 CRUD 模块、接入 gRPC API、使用 Pro 组件库构建页面、配置路由与国际化。所有示例均来自脚手架内置模块的真实代码。

GoWind Admin 二开发指南

本文档面向 基于 GoWind Admin 脚手架进行二次开发 的开发者。如果你要新建一个业务模块,请按以下流程操作。

项目概览

go-wind-vue3-element-admin — 基于 Vue 3 + Vite + TypeScript + Element Plus 的后台管理脚手架。

技术栈

类别 技术
框架 Vue 3.5 + TypeScript 5.9
构建 Vite 8 (Rolldown)
UI Element Plus 2.x + vxe-table 4.x
状态 Pinia 3 + @tanstack/vue-query 5
路由 vue-router 5
国际化 vue-i18n 11
CSS UnoCSS + SCSS
表单 vee-validate + zod
HTTP axios (封装 gRPC-Web 风格 API)

目录结构(二开人员关注点)

src/
├── api/
│   ├── generated/          # gRPC 自动生成代码(禁止手动修改)
│   ├── client.ts           # ApiClient 单例(transport 适配层)
│   └── composables/        # Composable 层:Vue Query hooks + 枚举工具
│       └── index.ts        # ⚠️ 新模块需在此添加 export *
├── components/Pro/         # Pro 组件库(配置化 CRUD)
├── core/
│   ├── transport/rest/     # 请求客户端、PaginationQuery、makeUpdateMask
│   ├── i18n/               # 国际化核心($t / t)
│   ├── router/             # 路由工具
│   └── access/             # 权限控制
├── pages/app/              # ⭐ 业务页面(按模块分目录)
├── locales/                # 翻译资源(zh-CN / en-US)
│   ├── zh-CN/
│   │   ├── common.json     # 通用文本(按钮、表单、表格等)
│   │   ├── enum.json       # ⚠️ 新枚举翻译在此追加
│   │   ├── routes.json     # ⚠️ 新路由标题在此追加
│   │   └── pages/          # ⚠️ 每个模块一个 JSON 文件
│   └── en-US/              # 英文翻译(同结构)
├── router/routes/modules/app/  # ⭐ 动态路由(自动扫描)
├── constants/index.ts      # 全局常量(DRAWER_WIDTH 等)
└── styles/                 # 全局样式

新建业务模块(完整流程)

以创建一个 「产品管理 (product)」 模块为例,需创建以下文件(按顺序):

总览:9 个文件

# 文件 用途
1 src/api/composables/product.ts Vue Query hooks + 枚举(通过 apiClient 调用)
2 src/api/composables/index.ts 追加 export *
3 src/locales/zh-CN/pages/product.json 页面翻译
4 src/locales/en-US/pages/product.json 英文翻译
5 src/locales/zh-CN/enum.json 追加枚举翻译
6 src/locales/zh-CN/routes.json 追加路由标题
7 src/router/routes/modules/app/product.ts 路由配置
8 src/pages/app/product/index.vue 列表页
9 src/pages/app/product/product-drawer.vue 新增/编辑抽屉

步骤 1:API Composable 层 (src/api/composables/product.ts)

通过 apiClient 调用 gRPC 服务,封装为 Vue Query hooks:

// src/api/composables/product.ts
import { computed } from "vue";
import {
  useMutation, useQuery,
  type UseMutationOptions, type UseQueryOptions,
} from "@tanstack/vue-query";
import type {
  productv1_DeleteProductRequest,
  productv1_GetProductRequest,
  productv1_ListProductResponse,
  productv1_Product,
} from "@/api/generated/admin/service/v1";
import { makeUpdateMask, type PaginationQuery } from "@/core/transport/rest";
import { apiClient } from "@/api/client";
import { queryClient } from "@plugins/vue-query";
import { i18n } from "@/core/i18n";

const t = i18n.global.t;

// ==============================
// Vue Query Hooks
// ==============================

// 列表查询 Hook(响应式,组件 setup 中使用)
export function useListProducts(
  query: PaginationQuery,
  options?: UseQueryOptions<productv1_ListProductResponse, Error>
) {
  return useQuery({
    queryKey: ["listProducts", query],
    queryFn: () => apiClient.productService.List(query.toRawParams()),
    ...options,
  });
}

// 列表查询(非 Hook,用于事件处理、手动调用)
export async function fetchListProducts(params: PaginationQuery) {
  return queryClient.fetchQuery({
    queryKey: ["listProducts", params],
    queryFn: () => apiClient.productService.List(params.toRawParams()),
    retry: 0,
  });
}

// 创建 — 注意 { data: {...} } 包裹
export function useCreateProduct(options?: UseMutationOptions<{}, Error, Record<string, any>>) {
  return useMutation({
    mutationFn: (values) => apiClient.productService.Create({ data: { ...values } as productv1_Product }),
    ...options,
  });
}

// 更新 — 必须使用 makeUpdateMask 生成字段掩码
export function useUpdateProduct(
  options?: UseMutationOptions<{}, Error, { id: number; values: Record<string, any> }>
) {
  return useMutation({
    mutationFn: ({ id, values }: { id: number; values: Record<string, any> }) =>
      apiClient.productService.Update({
        id,
        data: { ...values },
        updateMask: makeUpdateMask(Object.keys(values ?? {})),
      }),
    ...options,
  });
}

// 删除
export function useDeleteProduct(
  options?: UseMutationOptions<{}, Error, productv1_DeleteProductRequest>
) {
  return useMutation({ mutationFn: (req) => apiClient.productService.Delete(req), ...options });
}

// ==============================
// 枚举工具函数
// ==============================

// 枚举列表 — computed + i18n
export const productStatusList = computed(() => [
  { value: "ON", label: t("enum.status.ON") },
  { value: "OFF", label: t("enum.status.OFF") },
]);

// 枚举值 → 显示名称
export function productStatusToName(status: string) {
  const values = productStatusList.value;
  const matchedItem = values.find((item) => item.value === status);
  return matchedItem ? matchedItem.label : "";
}

// 枚举值 → 颜色(支持明暗模式)
const PRODUCT_STATUS_COLOR_MAP: Record<string, string> = {
  ON: "#52C41A",
  OFF: "#8C8C8C",
  DEFAULT: "#C9CDD4",
};
export function productStatusToColor(status: string) {
  return PRODUCT_STATUS_COLOR_MAP[status] || PRODUCT_STATUS_COLOR_MAP.DEFAULT;
}

⚠️ 重要规则:

  • composable 层从 generated/ 只导入类型type import),运行时调用通过 apiClient
  • apiClient 的服务属性命名:apiClient.productServiceapiClient.userService 等(驼峰 + Service 后缀)
  • 列表查询直接传 query.toRawParams(),它会自动设置 sorting/offset/limit/token/filter/filterExprundefined
  • 创建 mutation 参数必须用 { data: {...} } 包裹
  • 更新 mutation 必须使用 makeUpdateMask 生成字段掩码
  • 枚举列表用 computed(() => [...]) + t() 标签
  • 创建文件后,在 src/api/composables/index.ts 中追加:export * from "./product";

步骤 2:国际化翻译

2.1 页面翻译 (src/locales/zh-CN/pages/product.json)

{
  "moduleName": "产品",
  "name": "产品名称",
  "code": "产品编码",
  "category": "产品分类",
  "price": "价格",
  "status": "状态",
  "description": "产品描述",
  "remark": "备注"
}

2.2 英文翻译 (src/locales/en-US/pages/product.json)

{
  "moduleName": "Product",
  "name": "Product Name",
  "code": "Product Code",
  "category": "Category",
  "price": "Price",
  "status": "Status",
  "description": "Description",
  "remark": "Remark"
}

2.3 枚举翻译(在 src/locales/zh-CN/enum.json 中追加)

{
  "product": {
    "status": {
      "ON": "上架",
      "OFF": "下架"
    }
  }
}

2.4 路由标题(在 src/locales/zh-CN/routes.json 中追加)

{
  "product": {
    "moduleName": "产品管理",
    "list": "产品列表"
  }
}

步骤 3:路由 (src/router/routes/modules/app/product.ts)

import type { RouteRecordRaw } from "vue-router";
import { Layout } from "@/layouts";

const routes: RouteRecordRaw[] = [
  {
    path: "/product",
    name: "ProductManagement",
    component: Layout,
    redirect: "/product/list",
    meta: {
      order: 5000,                          // 菜单排序(数字越大越靠后)
      icon: "lucide:package",               // UnoCSS lucide 图标
      title: "routes.product.moduleName",   // i18n key
      authority: ["sys:product_admin"],     // 权限标识
    },
    children: [
      {
        path: "list",
        name: "ProductList",
        meta: {
          order: 1,
          icon: "lucide:list",
          title: "routes.product.list",
          authority: ["sys:product_admin"],
        },
        component: () => import("@/pages/app/product/index.vue"),
      },
    ],
  },
];

export default routes;

⚠️ 路由规则:

  • 类型使用 RouteRecordRaw(不是 AppRoute
  • 顶层路由使用 Layout 组件包裹
  • meta.title 使用 i18n key
  • meta.icon 使用 lucide: 前缀
  • 文件导出 default 路由数组,自动被 import.meta.glob 扫描

步骤 4:列表页 (src/pages/app/product/index.vue)

<template>
  <div class="app-container h-full flex flex-1 flex-col">
    <ProPage ref="pageRef" :config="pageConfig" @add="handleAdd" @edit="handleEdit">
      <!-- 自定义插槽:状态列 -->
      <template #status="scope: any">
        <ElTag size="small" effect="dark" round :color="productStatusToColor(scope.row.status)">
          {{ productStatusToName(scope.row.status) }}
        </ElTag>
      </template>
    </ProPage>

    <!-- 新增/编辑抽屉 -->
    <ProductDrawer ref="drawerRef" @success="handleSuccess" />
  </div>
</template>

<script lang="ts" setup>
import { ref, computed } from "vue";
import { ElTag } from "element-plus";

import ProPage from "@/components/Pro/ProPage/index.vue";
import type { ProPageConfig } from "@/components/Pro/ProPage/types";
import ProductDrawer from "./product-drawer.vue";

import {
  fetchListProducts,
  useDeleteProduct,
  productStatusToName,
  productStatusToColor,
  productStatusList,
} from "@/api/composables";
import { PaginationQuery } from "@/core/transport/rest";
import { $t } from "@/core/i18n";

const { mutateAsync: deleteProduct } = useDeleteProduct();

const pageRef = ref();
const drawerRef = ref();

const pageConfig = computed<ProPageConfig>(() => ({
  skeleton: true,
  search: {
    grid: true,
    fields: [
      {
        type: "input",
        label: $t("pages.product.name"),
        field: "name",
        attrs: { placeholder: $t("common.placeholder.input"), clearable: true },
      },
      {
        type: "select",
        label: $t("common.table.status"),
        field: "status",
        attrs: { placeholder: $t("common.placeholder.select"), clearable: true },
        options: productStatusList.value,
      },
    ],
  },
  table: {
    listAction: async (query: any) => {
      const { page, pageSize, ...queryParams } = query;
      const result = await fetchListProducts(
        new PaginationQuery({
          paging: { page: page || 1, pageSize: pageSize || 10 },
          formValues: queryParams,
        })
      );
      return { items: result.items || [], total: result.total || 0 };
    },
    deleteAction: async (ids: string) => {
      await deleteProduct({ id: ids as any });
    },
    toolbar: [],
    toolbarRight: ["add"],
    defaultToolbar: ["refresh", "filter"],
    columns: [
      { type: "index", label: $t("common.table.seq"), width: 60 },
      { prop: "name", label: $t("pages.product.name"), minWidth: 120 },
      { prop: "code", label: $t("pages.product.code"), minWidth: 120 },
      { prop: "price", label: $t("pages.product.price"), minWidth: 100, align: "right" },
      {
        prop: "status",
        label: $t("common.table.status"),
        minWidth: 100,
        slotName: "status",
      },
      {
        prop: "createdAt",
        label: $t("common.table.createdAt"),
        minWidth: 160,
        cellType: "date",
        dateFormat: "YYYY-MM-DD HH:mm:ss",
      },
      { prop: "remark", label: $t("common.table.remark"), minWidth: 150 },
      {
        prop: "action",
        label: $t("common.table.action"),
        fixed: "right",
        width: 150,
        cellType: "tool",
        buttons: [
          { name: "edit", label: $t("common.button.edit"), icon: "lucide:pen-line" },
          { name: "delete", label: $t("common.button.delete"), icon: "lucide:trash-2", attrs: { type: "danger" } },
        ],
      },
    ],
  },
}));

function handleAdd() {
  drawerRef.value?.open({ create: true });
}

function handleEdit(row: any) {
  drawerRef.value?.open({ create: false, row });
}

function handleSuccess() {
  pageRef.value?.refresh();
}
</script>

<style lang="scss" scoped>
.app-container {
  padding: 20px;
  width: 100%;
  min-width: 0;
  flex-shrink: 0;
}
</style>

步骤 5:新增/编辑抽屉 (src/pages/app/product/product-drawer.vue)

<template>
  <ElDrawer
    v-model="visible"
    :title="title"
    :size="DRAWER_WIDTH"
    :close-on-click-modal="false"
    :append-to-body="true"
    :destroy-on-close="true"
    @close="handleClose"
  >
    <ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px" class="drawer-form">
      <ElFormItem :label="$t('pages.product.name')" prop="name">
        <ElInput v-model="formData.name" :placeholder="$t('common.placeholder.input')" clearable />
      </ElFormItem>

      <ElFormItem :label="$t('pages.product.code')" prop="code">
        <ElInput v-model="formData.code" :placeholder="$t('common.placeholder.input')" clearable />
      </ElFormItem>

      <ElFormItem :label="$t('pages.product.price')" prop="price">
        <ElInputNumber v-model="formData.price" :min="0" style="width: 100%" />
      </ElFormItem>

      <ElFormItem :label="$t('common.table.status')" prop="status">
        <ElRadioGroup v-model="formData.status">
          <ElRadioButton v-for="item in productStatusList" :key="item.value" :value="item.value">
            {{ item.label }}
          </ElRadioButton>
        </ElRadioGroup>
      </ElFormItem>

      <ElFormItem :label="$t('common.table.remark')" prop="remark">
        <ElInput v-model="formData.remark" type="textarea" :rows="3" :placeholder="$t('common.placeholder.input')" />
      </ElFormItem>
    </ElForm>

    <template #footer>
      <div class="drawer-footer">
        <ElButton @click="handleClose">{{ $t("common.button.cancel") }}</ElButton>
        <ElButton type="primary" :loading="submitLoading" @click="handleSubmit">
          {{ $t("common.button.confirm") }}
        </ElButton>
      </div>
    </template>
  </ElDrawer>
</template>

<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";

import { useCreateProduct, useUpdateProduct, productStatusList } from "@/api/composables";
import { $t } from "@/core/i18n";
import { DRAWER_WIDTH } from "@/constants";

const emit = defineEmits<{ success: [] }>();

const { mutateAsync: createProduct } = useCreateProduct();
const { mutateAsync: updateProduct } = useUpdateProduct();

const visible = ref(false);
const submitLoading = ref(false);
const isCreate = ref(true);
const currentId = ref<number>();
const formRef = ref();

const formData = reactive({
  name: "",
  code: "",
  price: 0,
  status: "ON",
  remark: "",
});

const formRules = {
  name: [{ required: true, message: $t("common.validation.required"), trigger: "blur" }],
  code: [{ required: true, message: $t("common.validation.required"), trigger: "blur" }],
  status: [{ required: true, message: $t("common.validation.selectRequired"), trigger: "change" }],
};

const title = computed(() =>
  isCreate.value
    ? $t("common.modal.create", { moduleName: $t("pages.product.moduleName") })
    : $t("common.modal.update", { moduleName: $t("pages.product.moduleName") })
);

async function open(data?: { create: boolean; row?: any }) {
  visible.value = true;
  isCreate.value = data?.create ?? true;
  currentId.value = data?.row?.id;
  resetForm();
  if (!isCreate.value && data?.row) {
    Object.assign(formData, data.row);
  }
}

function handleClose() {
  visible.value = false;
  resetForm();
}

function resetForm() {
  formData.name = "";
  formData.code = "";
  formData.price = 0;
  formData.status = "ON";
  formData.remark = "";
  formRef.value?.clearValidate();
}

async function handleSubmit() {
  if (!formRef.value) return;
  try {
    await formRef.value.validate();
    submitLoading.value = true;
    const values = { ...formData };
    if (isCreate.value) {
      await createProduct(values);
      ElMessage.success($t("common.notification.createSuccess"));
    } else {
      await updateProduct({ id: currentId.value!, values });
      ElMessage.success($t("common.notification.updateSuccess"));
    }
    emit("success");
    handleClose();
  } catch (error) {
    if (error !== false) {
      ElMessage.error(
        isCreate.value ? $t("common.notification.createFailed") : $t("common.notification.updateFailed")
      );
    }
  } finally {
    submitLoading.value = false;
  }
}

defineExpose({ open });
</script>

<style lang="scss" scoped>
.drawer-form { padding-right: 10px; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
</style>

编码规范速查

通用规范

  • TypeScript 接口不使用 I 前缀User 而非 IUser
  • 所有用户可见文本必须通过 $t() / t() 国际化,禁止硬编码中文
  • $t() 用于模板和 computed 中;t() 用于 composable 顶层(非响应式)
  • 路由类型使用 RouteRecordRaw
  • 包管理器使用 pnpm
  • Git 提交信息遵循 Conventional Commits 规范

API 层规范

  • composable 层从 generated/ 只导入类型type import),运行时调用通过 apiClient
  • apiClient 通过懒加载属性访问器按需创建各服务 Client(如 apiClient.userService
  • 列表查询直接传 query.toRawParams(),它会自动设置 sorting/offset/limit/token/filter/filterExprundefined
  • 创建 mutation 参数用 { data: {...} } 包裹
  • 更新 mutation 必须使用 makeUpdateMask 生成字段掩码
  • queryKey 全局唯一,格式为 ["操作名", 参数]
  • 删除 API 参数字段注意是 ids 还是 id(需查看生成类型)

组件规范

  • ElDrawer 必须设置 :append-to-body="true":destroy-on-close="true"
  • ElDialogappendTo 属性是字符串选择器(非布尔值)
  • ElTreeSelectvalue 不接受 undefined,用 null 或不设初始值
  • 暗黑模式下文本颜色使用 var(--el-text-color-*),避免硬编码
  • 数字列必须右对齐 (align: "right")
  • 抽屉宽度统一使用常量 DRAWER_WIDTH(来自 @/constants

样式规范

  • CSS 变量使用 --gowind-* 前缀,避免与 Element Plus --el-* 冲突
  • 主题色变量存储为 HSL 数值(非 hex 字符串)
  • 更新 CSS 变量使用 style.setProperty()

常见陷阱

陷阱 正确做法
gRPC 创建接口直接传对象 必须用 { data: {...} } 包裹
isFunction 做类型窄化 使用 typeof fn === "function"
PowerShell 中用 && 连接命令 使用分号 ;
ElLink underline 传 boolean 已废弃 boolean,使用字符串
vue-i18n $te 传 ns 选项对象 $te 不支持 ns 选项,用完整 key
路由数组 map+sort 后类型丢失 断言为 RouteRecordRaw[]
defineExpose 暴露的属性需 .value 父组件通过 ref 直接访问暴露的方法
ElTreeSelect value 为 undefined null 代替或不设初始值
IconifyIcon 组件不显示 需显式导入 import { IconifyIcon } from "@iconify/vue"

更多详细参考请查阅 reference.mdexamples.md

Install via CLI
npx skills add https://github.com/tx7do/go-wind-admin --skill gowind-admin-dev
Repository Details
star Stars 503
call_split Forks 135
navigation Branch main
article Path SKILL.md
More from Creator