chore: sync changes for v0.1.1

This commit is contained in:
万物街
2025-08-29 00:10:44 +08:00
parent 9dded57fb7
commit 4009b88ff0
73 changed files with 3128 additions and 1740 deletions

View File

@@ -0,0 +1,102 @@
import request from '@/utils/request'
/***************************************************** 文章表 ****************************************************/
/**
* 获取文章表列表
* @param params
* @returns
*/
export function getArticleList(params: Record<string, any>) {
return request.get(`cms/article`, {params})
}
/**
* 获取文章表详情
* @param id 文章表id
* @returns
*/
export function getArticleInfo(id: number) {
return request.get(`cms/article/${id}`);
}
/**
* 添加文章表
* @param params
* @returns
*/
export function addArticle(params: Record<string, any>) {
return request.post('cms/article', params, {showSuccessMessage: true})
}
/**
* 编辑文章表
* @param params
*/
export function editArticle(params: Record<string, any>) {
return request.put(`cms/article/${params.id}`, params, {showSuccessMessage: true})
}
/**
* 删除文章表
* @param id
* @returns
*/
export function deleteArticle(id: number) {
return request.delete(`cms/article/${id}`, {showSuccessMessage: true})
}
/***************************************************** 文章分类管理 ****************************************************/
/**
* 获取文章分类列表
* @param params
* @returns
*/
export function getArticleCategoryList(params: Record<string, any>) {
return request.get(`cms/category`, {params})
}
/**
* 获取文章全部分类
* @param params
* @returns
*/
export function getArticleCategoryAll(params: Record<string, any>) {
return request.get(`cms/category/all`, params)
}
/**
* 获取文章分类详情
* @param category_id
*/
export function getArticleCategoryInfo(category_id: number) {
return request.get(`cms/category/${category_id}`);
}
/**
* 添加文章分类
* @param params
* @returns
*/
export function addArticleCategory(params: Record<string, any>) {
return request.post('cms/category', params, {showSuccessMessage: true})
}
/**
* 编辑文章分类
* @param params
* @returns
*/
export function editArticleCategory(params: Record<string, any>) {
return request.put(`cms/category/${params.category_id}`, params, {showSuccessMessage: true})
}
/**
* 文章分类删除
* @param category_id
*/
export function deleteArticleCategory(category_id: number) {
return request.delete(`cms/category/${category_id}`, {showSuccessMessage: true});
}

View File

@@ -0,0 +1,17 @@
{
"name": "栏目名称",
"sort": "排序",
"isShow": "是否显示",
"namePlaceholder": "请输入栏目名称",
"sortPlaceholder": "请输入排序",
"isShowPlaceholder": "是否显示",
"addArticleCategory": "添加栏目",
"updateArticleCategory": "编辑栏目",
"articleCategoryDeleteTips": "确定要删除该栏目吗?",
"nameMax": "名称不能超过20个字符",
"sortNumber": "排序号必须是数字",
"sortBetween": "排序号不能超过10000",
"show": "显示",
"hide": "不显示",
"articleNumber": "文章数量"
}

View File

@@ -0,0 +1,36 @@
{
"categoryName": "文章栏目",
"title": "文章标题",
"intro": "简介",
"summary": "文章摘要",
"image": "文章图片",
"author": "作者",
"content": "文章内容",
"visit": "实际浏览量",
"visitVirtual": "初始浏览量",
"isShow": "是否显示",
"sort": "排序",
"categoryIdPlaceholder": "请选择文章栏目",
"titlePlaceholder": "请输入文章标题",
"introPlaceholder": "请输入简介",
"summaryPlaceholder": "请输入文章摘要",
"imagePlaceholder": "请上传文章图片",
"authorPlaceholder": "请输入作者",
"contentPlaceholder": "请输入文章内容",
"visitPlaceholder": "请输入实际浏览量",
"visitVirtualPlaceholder": "请输入初始浏览量",
"isShowPlaceholder": "是否显示",
"sortPlaceholder": "请输入排序",
"addArticle": "添加文章",
"updateArticle": "编辑文章",
"titleMax": "文章标题不能超过20个字符",
"introMax": "文章简介不能超过50个字符",
"summaryMax": "文章摘要不能超过50个字符",
"imageMax": "图片路径太长",
"authorMax": "文章作者不能超过20个字符",
"isShowNumber": "是否显示必须是数字",
"isShowBetween": "是否显示只能是0或者1",
"sortNumber": "排序号必须是数字",
"sortBetween": "排序号需要在0-10000之间",
"articleNull": "未读取到文章信息!"
}

View File

@@ -0,0 +1,21 @@
{
"categoryName": "栏目",
"ID": "ID",
"title": "标题",
"intro": "简介",
"summary": "摘要",
"image": "封面",
"author": "作者",
"content": "文章内容",
"visit": "浏览量",
"visitVirtual": "初始浏览量",
"isShow": "是否显示",
"sort": "排序",
"createTime": "创建时间",
"updateTime": "更新时间",
"addArticle": "添加文章",
"updateArticle": "编辑文章",
"titlePlaceholder": "请输入文章标题",
"categoryIdPlaceholder": "请选择文章栏目",
"articleDeleteTips": "确定要删除该文章吗?"
}

View File

@@ -0,0 +1,12 @@
{
"articleData": "文章数据",
"articleStyle": "文章样式",
"articleBgColor": "文章背景",
"articleNum": "文章数量",
"selectArticleTips": "文章选择",
"articleTitle": "标题",
"articleImage": "封面",
"articleCategoryName": "栏目",
"articleSummary": "摘要",
"selectArticleTip": "请选择文章"
}

View File

@@ -0,0 +1,144 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<div class="flex justify-between items-center">
<span class="text-page-title">{{ pageName }}</span>
<el-button type="primary" @click="addEvent">{{ t('addArticleCategory') }}</el-button>
</div>
<el-card class="box-card !border-none my-[10px] table-search-wrap" shadow="never">
<el-form :inline="true" :model="categoryTableData.searchParam" ref="searchFormRef">
<el-form-item :label="t('name')" prop="name">
<el-input v-model.trim="categoryTableData.searchParam.name" :placeholder="t('namePlaceholder')" class="w-[190px]" prefix-icon="Search" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadCategoryList()">{{ t('search') }}</el-button>
<el-button @click="resetForm(searchFormRef)">{{ t('reset') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="mt-[10px]">
<el-table :data="categoryTableData.data" size="large" v-loading="categoryTableData.loading">
<template #empty>
<span>{{ !categoryTableData.loading ? t('emptyData') : '' }}</span>
</template>
<el-table-column prop="name" :label="t('name')" min-width="150" />
<el-table-column prop="article_num" :label="t('articleNumber')" min-width="140" />
<el-table-column prop="is_show" :label="t('isShow')" min-width="150">
<template #default="{ row }">
{{ row.is_show == 1 ? t('show') : t('hide') }}
</template>
</el-table-column>
<el-table-column prop="sort" :label="t('sort')" min-width="120" />
<el-table-column :label="t('operation')" fixed="right" width="130" align="right">
<template #default="{ row }">
<el-button type="primary" link @click="editEvent(row)">{{ t('edit') }}</el-button>
<el-button type="primary" link @click="deleteEvent(row.category_id)">{{ t('delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] flex justify-end">
<el-pagination v-model:current-page="categoryTableData.page" v-model:page-size="categoryTableData.limit" layout="total, sizes, prev, pager, next, jumper" :total="categoryTableData.total" @size-change="loadCategoryList()" @current-change="loadCategoryList" />
</div>
</div>
<edit-category ref="editCategoryDialog" @complete="loadCategoryList()" />
</el-card>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { t } from '@/lang'
import { getArticleCategoryList, deleteArticleCategory } from '@/addon/cms/api/article'
import { ElMessageBox, FormInstance } from 'element-plus'
import EditCategory from '@/addon/cms/views/article/components/edit-category.vue'
import { debounce } from '@/utils/common'
import { useRoute } from 'vue-router'
const route = useRoute()
const pageName = route.meta.title
const categoryTableData = reactive({
page: 1,
limit: 10,
total: 0,
loading: true,
data: [],
searchParam: {
name: ''
}
})
const searchFormRef = ref<FormInstance>()
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.resetFields()
loadCategoryList()
}
/**
* 获取文章分类列表
*/
const loadCategoryList = debounce((page: number = 1) => {
categoryTableData.loading = true
categoryTableData.page = page
getArticleCategoryList({
page: categoryTableData.page,
limit: categoryTableData.limit,
...categoryTableData.searchParam
}).then(res => {
categoryTableData.loading = false
categoryTableData.data = res.data.data
categoryTableData.total = res.data.total
}).catch(() => {
categoryTableData.loading = false
})
})
loadCategoryList()
const editCategoryDialog: Record<string, any> | null = ref(null)
/**
* 添加文章分类
*/
const addEvent = () => {
editCategoryDialog.value.setFormData()
editCategoryDialog.value.showDialog = true
}
/**
* 编辑文章分类
* @param data
*/
const editEvent = (data: any) => {
editCategoryDialog.value.setFormData(data)
editCategoryDialog.value.showDialog = true
}
/**
* 删除文章分类
*/
const deleteEvent = (id: number) => {
ElMessageBox.confirm(t('articleCategoryDeleteTips'), t('warning'),
{
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
}
).then(() => {
deleteArticleCategory(id).then(() => {
loadCategoryList()
}).catch(() => {
})
})
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,133 @@
<template>
<el-dialog v-model="showDialog" :title="popTitle" width="500px" :destroy-on-close="true">
<el-form :model="formData" label-width="90px" ref="formRef" :rules="formRules" class="page-form" v-loading="loading">
<el-form-item :label="t('name')" prop="name">
<el-input v-model="formData.name" clearable :placeholder="t('namePlaceholder')" class="input-width" />
</el-form-item>
<el-form-item :label="t('sort')" prop="sort">
<el-input-number v-model="formData.sort" :min="0" />
</el-form-item>
<el-form-item :label="t('isShow')">
<el-radio-group v-model="formData.is_show" :placeholder="t('isShowPlaceholder')">
<el-radio :label="1">{{ t('show') }}</el-radio>
<el-radio :label="0">{{ t('hidden') }}</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showDialog = false">{{ t('cancel') }}</el-button>
<el-button type="primary" :loading="loading" @click="confirm(formRef)">{{ t('confirm') }}</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { t } from '@/lang'
import type { FormInstance } from 'element-plus'
import { addArticleCategory, editArticleCategory, getArticleCategoryInfo } from '@/addon/cms/api/article'
let popTitle: string = ''
const showDialog = ref(false)
const loading = ref(true)
/**
* 表单数据
*/
const initialFormData = {
category_id: '',
name: '',
sort: '',
is_show: 1
}
const formData: Record<string, any> = reactive({ ...initialFormData })
const formRef = ref<FormInstance>()
// 表单验证规则
const formRules = computed(() => {
return {
name: [
{ required: true, message: t('namePlaceholder'), trigger: 'blur' },
{
validator: (rule: any, value: string, callback: any) => {
if (value.length > 20) {
callback(new Error(t('nameMax')))
}
callback()
},
trigger: 'blur'
}
],
sort: [
{
validator: (rule: any, value: string | number, callback: any) => {
if (value === '' || isNaN(value as number)) {
callback(new Error(t('sortNumber')))
}
if (parseInt(value as string) > 10000) {
callback(new Error(t('sortBetween')))
}
callback()
},
trigger: 'blur'
}
]
}
})
const emit = defineEmits(['complete'])
/**
* 确认
* @param formEl
*/
const confirm = async (formEl: FormInstance | undefined) => {
if (loading.value || !formEl) return
const save = formData.category_id ? editArticleCategory : addArticleCategory
await formEl.validate(async (valid) => {
if (valid) {
loading.value = true
const data = formData
save(data).then(res => {
loading.value = false
showDialog.value = false
emit('complete')
}).catch(() => {
loading.value = false
})
}
})
}
const setFormData = async (row: any = null) => {
loading.value = true
Object.assign(formData, initialFormData)
popTitle = t('addArticleCategory')
if (row) {
popTitle = t('updateArticleCategory')
const data = await (await getArticleCategoryInfo(row.category_id)).data
Object.keys(formData).forEach((key: string) => {
if (data[key] != undefined) formData[key] = data[key]
})
}
loading.value = false
}
defineExpose({
showDialog,
setFormData
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="main-container">
<el-card class="card !border-none mb-[15px]" shadow="never">
<el-page-header :content="pageName" :icon="ArrowLeft" @back="router.push({ path: '/cms/article/list' })" />
</el-card>
<el-card class="box-card !border-none" shadow="never">
<el-form :model="formData" label-width="90px" ref="formRef" :rules="formRules" class="page-form" v-loading="loading">
<el-form-item :label="t('title')" prop="title">
<el-input v-model.trim="formData.title" clearable :placeholder="t('titlePlaceholder')" class="input-width" maxlength="20" />
</el-form-item>
<el-form-item :label="t('categoryName')" prop="category_id">
<el-select v-model="formData.category_id" clearable :placeholder="t('categoryIdPlaceholder')" class="input-width">
<el-option :label="item['name']" :value="item['category_id']" v-for="(item,index) in categoryList" :key="index" />
</el-select>
</el-form-item>
<el-form-item :label="t('intro')" prop="intro">
<el-input v-model.trim="formData.intro" type="textarea" rows="4" clearable :placeholder="t('introPlaceholder')" class="input-width" maxlength="50" />
</el-form-item>
<el-form-item :label="t('summary')" prop="summary">
<el-input v-model.trim="formData.summary" type="textarea" rows="4" clearable :placeholder="t('summaryPlaceholder')" class="input-width" maxlength="50" />
</el-form-item>
<el-form-item :label="t('image')">
<upload-image v-model="formData.image" />
</el-form-item>
<el-form-item :label="t('author')" prop="author">
<el-input v-model.trim="formData.author" clearable :placeholder="t('authorPlaceholder')" class="input-width" maxlength="20" />
</el-form-item>
<el-form-item :label="t('content')" prop="content">
<editor v-model="formData.content" />
</el-form-item>
<el-form-item :label="t('visitVirtual')">
<el-input v-model.trim="formData.visit_virtual" clearable :placeholder="t('visitVirtualPlaceholder')" class="input-width" />
</el-form-item>
<el-form-item :label="t('isShow')">
<el-radio-group v-model="formData.is_show" :placeholder="t('isShowPlaceholder')">
<el-radio :label="1">{{ t('show') }}</el-radio>
<el-radio :label="0">{{ t('hidden') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('sort')" prop="sort">
<el-input-number v-model="formData.sort" :min="0" />
</el-form-item>
</el-form>
</el-card>
<div class="fixed-footer-wrap">
<div class="fixed-footer">
<el-button type="primary" @click="onSave(formRef)">{{ t('save') }}</el-button>
<el-button @click="back()">{{ t('cancel') }}</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { t } from '@/lang'
import type { FormInstance } from 'element-plus'
import { getArticleInfo, getArticleCategoryAll, addArticle, editArticle } from '@/addon/cms/api/article'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const id: number = parseInt(route.query.id as string || '0')
const loading = ref(false)
const categoryList = ref([])
const pageName = route.meta.title
/**
* 表单数据
*/
const initialFormData = {
id: '',
category_id: '',
title: '',
intro: '',
summary: '',
image: '',
author: '',
content: '',
visit: '',
visit_virtual: '',
is_show: 1,
sort: 0
}
const formData: Record<string, any> = reactive({ ...initialFormData })
const setFormData = async (id: number = 0) => {
loading.value = true
Object.assign(formData, initialFormData)
if (id) {
const data = await (await getArticleInfo(id)).data
if (!data || Object.keys(data).length == 0) {
ElMessage.error(t('articleNull'))
setTimeout(() => {
router.go(-1)
}, 2000)
return false
}
Object.keys(formData).forEach((key: string) => {
if (data[key] != undefined) formData[key] = data[key]
})
loading.value = false
} else {
loading.value = false
}
}
if (id) setFormData(id)
const setCategoryList = async () => {
categoryList.value = await (await getArticleCategoryAll({})).data
// if (!id && categoryList.value.length > 0) formData.category_id = categoryList.value[0].category_id
}
setCategoryList()
const formRef = ref<FormInstance>()
// 表单验证规则
const formRules = computed(() => {
return {
title: [
{ required: true, message: t('titlePlaceholder'), trigger: 'blur' }
],
category_id: [
{ required: true, message: t('categoryIdPlaceholder'), trigger: 'blur' }
],
content: [
{ required: true, message: t('contentPlaceholder'), trigger: 'blur' },
{
validator: (rule: any, value: string, callback: any) => {
const content = value.replace(/<[^<>]+>/g, '').replace(/&nbsp;/gi, '')
if (!content && value.indexOf('img') === -1) {
callback(new Error(t('contentPlaceholder')))
} else callback()
},
trigger: ['blur', 'change']
}
]
}
})
const onSave = async (formEl: FormInstance | undefined) => {
if (loading.value || !formEl) return
await formEl.validate(async (valid) => {
if (valid) {
loading.value = true
const data = formData
const save = id ? editArticle : addArticle
save(data).then(res => {
loading.value = false
back()
}).catch(() => {
loading.value = false
})
}
})
}
const back = () => {
router.push({ path: '/cms/article/list' })
}
</script>
<style lang="scss" scoped>
.edui-default .edui-editor {
border: none!important;
z-index: 1!important;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<div class="flex justify-between items-center">
<span class="text-page-title">{{ pageName }}</span>
<el-button type="primary" @click="addEvent">{{ t('addArticle') }}</el-button>
</div>
<el-card class="box-card !border-none my-[10px] table-search-wrap" shadow="never">
<el-form :inline="true" :model="articleTableData.searchParam" ref="searchFormRef">
<el-form-item :label="t('title')" prop="title">
<el-input v-model="articleTableData.searchParam.title" :placeholder="t('titlePlaceholder')" />
</el-form-item>
<el-form-item :label="t('categoryName')" prop="category_id">
<el-select v-model="articleTableData.searchParam.category_id" clearable :placeholder="t('categoryIdPlaceholder')" class="input-width">
<el-option :label="t('selectPlaceholder')" value="" />
<el-option :label="item['name']" :value="item['category_id']" v-for="(item,index) in categoryList" :key="index"/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadArticleList()">{{ t('search') }}</el-button>
<el-button @click="resetForm(searchFormRef)">{{ t('reset') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="mt-[10px]">
<el-table :data="articleTableData.data" size="large" v-loading="articleTableData.loading">
<template #empty>
<span>{{ !articleTableData.loading ? t('emptyData') : '' }}</span>
</template>
<el-table-column prop="id" :show-overflow-tooltip="true" :label="t('ID')" width="100" />
<el-table-column prop="category_name" :label="t('categoryName')" width="120" />
<el-table-column prop="title" :show-overflow-tooltip="true" :label="t('title')" width="180">
<template #default="{ row }">
<a :href="row.article_url.web_url" target="_blank">{{ row.title }}</a>
</template>
</el-table-column>
<el-table-column :label="t('image')" min-width="120" align="center">
<template #default="{ row }">
<el-image class="w-12 h-12" v-if="row.image_thumb_small" :src="img(row.image_thumb_small)" fit="contain" />
</template>
</el-table-column>
<el-table-column prop="visit" :label="t('visit')" width="120" align="center">
<template #default="{ row }">
<span>{{ parseInt(row.visit + row.visit_virtual) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('isShow')" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row.is_show == 1">{{ t('show') }}</span>
<span v-if="row.is_show == 0">{{t('hidden')}}</span>
</template>
</el-table-column>
<el-table-column prop="sort" :label="t('sort')" width="100" align="center" />
<el-table-column :label="t('createTime')" min-width="180" align="center">
<template #default="{ row }">
{{ row.create_time || '' }}
</template>
</el-table-column>
<el-table-column :label="t('operation')" fixed="right" align="right" width="130">
<template #default="{ row }">
<el-button type="primary" link @click="editEvent(row)">{{ t('edit') }}</el-button>
<el-button type="primary" link @click="deleteEvent(row.id)">{{ t('delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] flex justify-end">
<el-pagination v-model:current-page="articleTableData.page" v-model:page-size="articleTableData.limit" layout="total, sizes, prev, pager, next, jumper" :total="articleTableData.total" @size-change="loadArticleList()" @current-change="loadArticleList" />
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { t } from '@/lang'
import { getArticleList, deleteArticle, getArticleCategoryAll } from '@/addon/cms/api/article'
import { img } from '@/utils/common'
import { ElMessageBox, FormInstance } from 'element-plus'
import { useRouter, useRoute } from 'vue-router'
const route = useRoute()
const pageName = route.meta.title
const articleTableData = reactive({
page: 1,
limit: 10,
total: 0,
loading: true,
data: [],
searchParam: {
title: '',
category_id: ''
}
})
const categoryList = ref([])
const searchFormRef = ref<FormInstance>()
const setCategoryList = async () => {
categoryList.value = await (await getArticleCategoryAll({})).data
}
setCategoryList()
/**
* 获取文章列表
*/
const loadArticleList = (page: number = 1) => {
articleTableData.loading = true
articleTableData.page = page
getArticleList({
page: articleTableData.page,
limit: articleTableData.limit,
...articleTableData.searchParam
}).then(res => {
articleTableData.loading = false
articleTableData.data = res.data.data
articleTableData.total = res.data.total
}).catch(() => {
articleTableData.loading = false
})
}
loadArticleList()
const router = useRouter()
/**
* 添加文章
*/
const addEvent = () => {
router.push('/cms/article/edit')
}
/**
* 编辑文章
* @param data
*/
const editEvent = (data: any) => {
router.push(`/cms/article/edit?id=${data.id}`)
}
/**
* 删除文章
*/
const deleteEvent = (id: number) => {
ElMessageBox.confirm(t('articleDeleteTips'), t('warning'),
{
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
}
).then(() => {
deleteArticle(id).then(() => {
loadArticleList()
}).catch(() => {
})
})
}
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.resetFields()
loadArticleList()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,173 @@
<template>
<!-- 内容 -->
<div class="content-wrap" v-show="diyStore.editTab == 'content'">
<div class="edit-attr-item-wrap">
<h3 class="mb-[10px]">{{ t('articleData') }}</h3>
<el-form label-width="80px" class="px-[10px]">
<el-form-item :label="t('dataSources')">
<el-radio-group v-model="diyStore.editComponent.sources">
<el-radio :label="'initial'">{{ t('defaultSources') }}</el-radio>
<el-radio :label="'diy'">{{ t('manualSelectionSources') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('articleNum')" v-show="diyStore.editComponent.sources == 'initial'">
<el-slider v-model="diyStore.editComponent.count" show-input size="small" class="ml-[10px] diy-nav-slider" :min="1" :max="30" />
</el-form-item>
<el-form-item :label="t('manualSelectionSources')" v-show="diyStore.editComponent.sources == 'diy'" class=" flex">
<span @click="showArticle" class="cursor-pointer flex-1" :class="{ 'text-primary': diyStore.editComponent.articleIds.length > 0 }">{{ diyStore.editComponent.articleIds.length > 0 ? t('selected') + diyStore.editComponent.articleIds.length + t('piece') : t('selectPlaceholder') }}</span>
<el-icon>
<ArrowRight />
</el-icon>
</el-form-item>
</el-form>
</div>
<el-dialog v-model="showDialog" :title="t('selectArticleTips')" width="60%" :close-on-click-modal="false">
<div>
<el-table :data="articleTableData.data" size="large" v-loading="articleTableData.loading" @selection-change="handleSelectionChange">
<template #empty>
<span>{{ !articleTableData.loading ? t('emptyData') : '' }}</span>
</template>
<el-table-column type="selection" width="55" />
<el-table-column prop="title" :show-overflow-tooltip="true" :label="t('articleTitle')" width="140" />
<el-table-column :label="t('articleImage')" min-width="120" align="center">
<template #default="{ row }">
<el-image class="w-12 h-12" v-if="row.image" :src="img(row.image)" fit="contain" />
</template>
</el-table-column>
<el-table-column prop="category_name" :label="t('articleCategoryName')" align="center" min-width="140" />
<el-table-column prop="summary" :label="t('articleSummary')" width="180" :show-overflow-tooltip="true" />
<el-table-column :label="t('createTime')" min-width="180" align="center">
<template #default="{ row }">
{{ row.create_time || '' }}
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] flex justify-end">
<el-pagination v-model:current-page="articleTableData.page" v-model:page-size="articleTableData.limit"
layout="total, sizes, prev, pager, next, jumper" :total="articleTableData.total"
@size-change="loadArticleList()" @current-change="loadArticleList" />
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showDialog = false">{{ t('cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('confirm') }}</el-button>
</span>
</template>
</el-dialog>
</div>
<!-- 样式 -->
<div class="style-wrap" v-show="diyStore.editTab == 'style'">
<div class="edit-attr-item-wrap">
<h3 class="mb-[10px]">{{ t('articleStyle') }}</h3>
<el-form label-width="80px" class="px-[10px]">
<el-form-item :label="t('articleBgColor')">
<el-color-picker v-model="diyStore.editComponent.elementBgColor" show-alpha :predefine="diyStore.predefineColors" />
</el-form-item>
<el-form-item :label="t('textColor')">
<el-color-picker v-model="diyStore.editComponent.textColor"/>
</el-form-item>
<el-form-item :label="t('topRounded')">
<el-slider v-model="diyStore.editComponent.topElementRounded" show-input size="small" class="ml-[10px] diy-nav-slider" :max="50" />
</el-form-item>
<el-form-item :label="t('bottomRounded')">
<el-slider v-model="diyStore.editComponent.bottomElementRounded" show-input size="small" class="ml-[10px] diy-nav-slider" :max="50" />
</el-form-item>
</el-form>
</div>
<!-- 组件样式 -->
<slot name="style"></slot>
</div>
</template>
<script lang="ts" setup>
import { t } from '@/lang'
import useDiyStore from '@/stores/modules/diy'
import { ref, reactive } from 'vue'
import { img } from '@/utils/common'
import { getArticleList } from '@/addon/cms/api/article'
const diyStore: any = useDiyStore()
diyStore.editComponent.ignore = [] // 忽略公共属性
// 组件验证
diyStore.editComponent.verify = (index: number) => {
const res = { code: true, message: '' }
if (diyStore.value[index].sources === 'diy' && diyStore.value[index].articleIds.length === 0) {
res.code = false
res.message = t('selectArticleTip')
}
return res
}
const showDialog = ref(false)
const showArticle = () => {
showDialog.value = true
}
const articleTableData = reactive({
page: 1,
limit: 10,
total: 0,
loading: true,
data: [],
searchParam: {
title: '',
category_id: '',
is_show: 1
}
})
/**
* 获取文章列表
*/
const loadArticleList = (page: number = 1) => {
articleTableData.loading = true
articleTableData.page = page
getArticleList({
page: articleTableData.page,
limit: articleTableData.limit,
...articleTableData.searchParam
}).then(res => {
articleTableData.loading = false
articleTableData.data = res.data.data
articleTableData.total = res.data.total
}).catch(() => {
articleTableData.loading = false
})
}
loadArticleList()
const multipleSelection: any = ref([])
const handleSelectionChange = (val: any[]) => {
multipleSelection.value = val
}
const save = () => {
diyStore.editComponent.articleIds = []
multipleSelection.value.forEach((item: any) => {
diyStore.editComponent.articleIds.push(item.id)
})
showDialog.value = false
}
defineExpose({})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router';
export const NO_LOGIN_ROUTES: string[] = ['/home/example/public'];
export const ROUTE: RouteRecordRaw[] = [
{
name: 'ExampleAddonRoot',
path: '/home/example',
meta: { title: '示例插件', app: 'home' },
component: () => import('#/layouts/app/home.vue'),
children: [
{
name: 'ExampleAddonPublic',
path: 'public',
meta: { title: '公开页面' },
component: () => import('../views/public.vue'),
},
{
name: 'ExampleAddonDashboard',
path: 'dashboard',
meta: { title: '插件仪表盘' },
component: () => import('../views/dashboard.vue'),
},
],
},
];
export default ROUTE;

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h3>示例插件 - 仪表盘需登录</h3>
<p>登录后访问/home/example/dashboard</p>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h3>示例插件 - 公开页面免登录</h3>
<p>这是一个无需登录即可访问的示例页面/home/example/public</p>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,8 @@
export const LAYOUT_OPTIONS = [
{ key: 'sidebar-nav', i18nKey: 'layout.vertical' },
{ key: 'sidebar-mixed-nav', i18nKey: 'layout.verticalMix' },
{ key: 'header-nav', i18nKey: 'layout.horizontal' },
{ key: 'header-sidebar-nav', i18nKey: 'layout.headerSidebar' },
] as const;
export type LayoutKey = (typeof LAYOUT_OPTIONS)[number]['key'];

View File

@@ -0,0 +1,17 @@
<template>
<BasicLayout />
</template>
<script setup lang="ts">
import BasicLayout from '#/layouts/basic.vue'
import { updatePreferences, usePreferences } from '@vben/preferences'
import { onMounted } from 'vue'
const { layout } = usePreferences()
onMounted(() => {
if (layout.value !== 'sidebar-mixed-nav') {
updatePreferences({ app: { layout: 'sidebar-mixed-nav' } })
}
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<BasicLayout />
</template>
<script setup lang="ts">
import BasicLayout from '#/layouts/basic.vue'
import { updatePreferences, usePreferences } from '@vben/preferences'
import { onMounted } from 'vue'
const { layout } = usePreferences()
onMounted(() => {
if (layout.value !== 'header-nav') {
updatePreferences({ app: { layout: 'header-nav' } })
}
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<BasicLayout />
</template>
<script setup lang="ts">
import BasicLayout from '#/layouts/basic.vue'
import { updatePreferences, usePreferences } from '@vben/preferences'
import { onMounted } from 'vue'
const { layout } = usePreferences()
onMounted(() => {
if (layout.value !== 'sidebar-nav') {
updatePreferences({ app: { layout: 'sidebar-nav' } })
}
})
</script>

View File

@@ -12,5 +12,10 @@
"title": "Dashboard",
"analytics": "Analytics",
"workspace": "Workspace"
},
"adminSetting": {
"layoutTitle": "Layout Settings",
"appList": "Application List",
"chooseLayout": "Choose Layout"
}
}

View File

@@ -12,5 +12,10 @@
"title": "概览",
"analytics": "分析页",
"workspace": "工作台"
},
"adminSetting": {
"layoutTitle": "布局设置",
"appList": "应用列表",
"chooseLayout": "选择布局"
}
}

View File

@@ -63,5 +63,11 @@
"permissions": "权限",
"setPermissions": "授权"
},
"title": "系统管理"
"title": "系统管理",
"layout": {
"header": "头部",
"sider": "侧边栏",
"footer": "底部",
"content": "内容"
}
}

View File

@@ -6,8 +6,18 @@ import { defineOverridesPreferences } from '@vben/preferences';
* !!! 更改配置后请清空缓存,否则可能不生效
*/
export const overridesPreferences = defineOverridesPreferences({
// overrides
// 可按需覆盖默认偏好
app: {
name: import.meta.env.VITE_APP_TITLE,
},
});
/**
* 根据路由前缀选择默认布局(可替换为后端/站点配置)
* 规范值参考:'sidebar-nav' | 'sidebar-mixed-nav' | 'header-nav' | 'header-mixed-nav' | 'header-sidebar-nav'
*/
export const APP_LAYOUT_BY_PREFIX: Record<string, string> = {
admin: 'sidebar-mixed-nav',
home: 'header-nav',
site: 'sidebar-nav',
};

View File

@@ -1,12 +1,14 @@
import type { Router } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { preferences, updatePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import { NO_LOGIN_ROUTES } from '#/router/routes';
import { APP_LAYOUT_BY_PREFIX } from '#/preferences';
import { generateAccess } from './access';
@@ -21,6 +23,13 @@ function setupCommonGuard(router: Router) {
router.beforeEach((to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 根据前缀切换布局(由配置驱动,可接后端/站点设置)
const first = to.path.replace(/^\/+/, '').split('/')[0] as string;
const desiredLayout = APP_LAYOUT_BY_PREFIX[first];
if (desiredLayout && preferences.app.layout !== desiredLayout) {
updatePreferences({ app: { layout: desiredLayout as any } });
}
// 页面加载进度条
if (!to.meta.loaded && preferences.transition.progress) {
startProgress();
@@ -36,6 +45,14 @@ function setupCommonGuard(router: Router) {
if (preferences.transition.progress) {
stopProgress();
}
// 设置标题:路由标题 + 站点/网站名(如有)
const titleParts: string[] = [];
if (typeof to.meta?.title === 'string') titleParts.push(to.meta.title);
// 这里可扩展注入 store 的网站/站点名
const websiteName = '';
if (websiteName) titleParts.push(websiteName);
if (titleParts.length) document.title = titleParts.join(' - ');
});
}
@@ -67,6 +84,11 @@ function setupAccessGuard(router: Router) {
return true;
}
// addon 定义的免登录白名单
if (NO_LOGIN_ROUTES?.includes(to.path)) {
return true;
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
return {
@@ -105,6 +127,8 @@ function setupAccessGuard(router: Router) {
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
// 计算首次进入首页路径
let redirectPath: string;
if (from.query.redirect) {
redirectPath = from.query.redirect as string;
@@ -115,6 +139,7 @@ function setupAccessGuard(router: Router) {
} else {
redirectPath = to.fullPath;
}
return {
...router.resolve(decodeURIComponent(redirectPath)),
replace: true,

View File

@@ -31,6 +31,40 @@ const router = createRouter({
const resetRoutes = () => resetStaticRoutes(router, routes);
// 获取当前 app 类型admin/site/home默认 admin
function getAppType(): 'admin' | 'site' | 'home' {
const path = location.pathname.replace(/^\/+/, '');
const first = path.split('/')[0];
if (first === 'site' || first === 'home' || first === 'admin') return first as any;
return 'admin';
}
// 重写 push自动补齐 app 前缀
const originPush = router.push.bind(router);
router.push = (to: any) => {
const route = typeof to === 'string' ? { path: to } : { ...to };
if (route.path) {
const parts = route.path.split('/').filter(Boolean);
if (!['admin', 'site', 'home'].includes(parts[0])) {
route.path = `/${getAppType()}${route.path.startsWith('/') ? '' : '/'}${route.path}`;
}
}
return originPush(route);
};
// 重写 resolve保证解析时也有 app 前缀
const originResolve = router.resolve.bind(router);
router.resolve = (to: any, currentLocation?: any) => {
const route = typeof to === 'string' ? { path: to } : { ...to };
if (route.path) {
const parts = route.path.split('/').filter(Boolean);
if (!['admin', 'site', 'home'].includes(parts[0])) {
route.path = `/${getAppType()}${route.path.startsWith('/') ? '' : '/'}${route.path}`;
}
}
return originResolve(route, currentLocation);
};
// 创建路由守卫
createRouterGuard(router);

View File

@@ -15,6 +15,22 @@ const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
/** 从 addon 中加载路由与免登录白名单(对齐 PHP admin 的插件机制) */
const addonRouteFiles = import.meta.glob('../../addon/**/router/index.ts', { eager: true });
const addonRoutes: RouteRecordRaw[] = [];
const addonNoLoginPaths: string[] = [];
Object.values(addonRouteFiles).forEach((mod: any) => {
if (Array.isArray(mod?.default)) {
addonRoutes.push(...mod.default);
}
if (Array.isArray(mod?.ROUTE)) {
addonRoutes.push(...mod.ROUTE);
}
if (Array.isArray(mod?.NO_LOGIN_ROUTES)) {
addonNoLoginPaths.push(...mod.NO_LOGIN_ROUTES);
}
});
/** 外部路由列表访问这些页面可以不需要Layout可能用于内嵌在别的系统(不会显示在菜单中) */
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
@@ -32,8 +48,8 @@ const routes: RouteRecordRaw[] = [
/** 基本路由列表,这些路由不需要进入权限拦截 */
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
/** 有权限校验的路由列表,包含动态路由静态路由与 addon 路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes, ...addonRoutes];
const componentKeys: string[] = Object.keys(
import.meta.glob('../../views/**/*.vue'),
@@ -45,3 +61,4 @@ const componentKeys: string[] = Object.keys(
});
export { accessRoutes, componentKeys, coreRouteNames, routes };
export const NO_LOGIN_ROUTES: string[] = addonNoLoginPaths;

View File

@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
name: 'AdminSettingRoot',
path: '/admin/setting',
meta: { title: '系统设置' },
component: () => import('#/layouts/app/admin.vue'),
children: [
{
name: 'AdminSettingLayout',
path: 'layout',
meta: { title: '布局设置' },
component: () => import('#/views/admin/setting/layout/index.vue'),
},
],
},
];
export default routes;

View File

@@ -0,0 +1,48 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
name: 'AdminRoot',
path: '/admin',
meta: { title: 'Admin' },
component: () => import('#/layouts/app/admin.vue'),
children: [
{
name: 'AdminIndex',
path: '',
component: () => import('#/views/app/admin/index.vue'),
meta: { title: 'Admin 控制台' },
},
],
},
{
name: 'HomeRoot',
path: '/home',
meta: { title: 'Home' },
component: () => import('#/layouts/app/home.vue'),
children: [
{
name: 'HomeIndex',
path: '',
component: () => import('#/views/app/home/index.vue'),
meta: { title: 'Home 门户' },
},
],
},
{
name: 'SiteRoot',
path: '/site',
meta: { title: 'Site' },
component: () => import('#/layouts/app/site.vue'),
children: [
{
name: 'SiteIndex',
path: '',
component: () => import('#/views/app/site/index.vue'),
meta: { title: 'Site 应用' },
},
],
},
];
export default routes;

View File

@@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
name: 'System',
path: '/system',
children: [
{
path: '/system/user',
name: 'SystemUser',
meta: {
icon: 'mdi:account-circle-outline',
title: $t('system.user.title'),
},
component: () => import('#/views/system/user/list.vue'),
},
{
path: '/system/role',
name: 'SystemRole',

View File

@@ -0,0 +1,50 @@
<template>
<Page>
<template #header>
<div class="flex items-center justify-between px-4 py-2">
<h3 class="text-lg font-medium">{{ $t('page.adminSetting.layoutTitle') }}</h3>
</div>
</template>
<div class="p-6">
<div class="mb-4 text-gray-500">{{ $t('page.adminSetting.appList') }}</div>
<div class="divide-y rounded border">
<div
v-for="app in apps"
:key="app.key"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="inline-flex size-8 items-center justify-center rounded bg-primary/10 text-primary">{{ app.icon }}</span>
<div>
<div class="font-medium">{{ app.name }}</div>
<div class="text-xs text-gray-500">{{ app.desc }}</div>
</div>
</div>
<Preferences :default-value="'layout'">
<a-button size="small">{{ $t('common.setting') }}</a-button>
</Preferences>
</div>
</div>
</div>
</Page>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Page } from '@vben/common-ui'
import { Preferences } from '@vben/layouts'
interface AppItem { key: string; name: string; desc: string; icon: string }
const apps = ref<AppItem[]>([
{ key: 'multi', name: '多应用', desc: '', icon: '多' },
{ key: 'shop', name: '商城系统', desc: '', icon: '商' },
{ key: 'vip', name: '会员卡管理', desc: '', icon: '会' },
{ key: 'tour', name: '旅游系统', desc: '', icon: '旅' },
{ key: 'weixin', name: '微官网', desc: '', icon: '微' },
{ key: 'service', name: '上门服务', desc: '', icon: '服' },
{ key: 'community', name: '种草社区', desc: '', icon: '社' },
])
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="p-4">
<h2>Admin 控制台</h2>
<p>这里是 Admin 根入口后续会注入菜单与首页跳转</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="p-4">
<h2>Home 门户</h2>
<p>这里是 Home 根入口后续会注入门户首页</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="p-4">
<h2>Site 应用</h2>
<p>这里是 Site 根入口后续会根据站点上下文跳转</p>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: [
{ field: 'id', title: 'ID', width: 120 },
{ field: 'username', title: $t('system.user.username'), width: 200 },
{ field: 'status', title: $t('system.user.status'), width: 120 },
{ field: 'createTime', title: $t('system.user.createTime'), width: 200 },
],
height: 'auto',
} as VxeTableGridOptions<any>,
});
</script>
<template>
<Page auto-content-height>
<Grid :table-title="$t('system.user.list')" />
</Page>
</template>

View File

@@ -8,5 +8,6 @@
}
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/addon/**"]
}

View File

@@ -14,13 +14,27 @@ const config = {
backendUrl: process.env.BACKEND_URL || 'http://localhost:3000',
docsDir: path.join(__dirname, '../src/wwjcloud/openapi/api'),
apiGroups: {
unified: {
name: '统一 API',
swaggerPath: '/docs-json',
outputDir: 'unified',
prefix: ''
}
}
full: {
name: '全量 API',
swaggerPath: process.env.OPENAPI_FULL_PATH || '/api-json',
outputDir: 'full',
prefix: '',
},
admin: {
name: '管理端 API',
swaggerPath: process.env.OPENAPI_ADMIN_PATH || '/api/admin-json',
outputDir: 'admin',
prefix: '/adminapi',
},
frontend: {
name: '前端 API',
swaggerPath: process.env.OPENAPI_FRONTEND_PATH || '/api/frontend-json',
outputDir: 'frontend',
prefix: '/api',
},
},
// 固定 Token与后端 swagger.token 保持一致)
token: '9f2a7c1e4b5d8a90c3f6e2b17a4c58d0f1b2c3d4e5f67890ab12cd34ef56a789',
};
/**
@@ -30,7 +44,9 @@ async function getSwaggerJson(group) {
try {
const url = `${config.backendUrl}${group.swaggerPath}`;
console.log(`获取 ${group.name} API: ${url}`);
const response = await axios.get(url);
const headers = {};
if (config.token) headers.Authorization = `Bearer ${config.token}`;
const response = await axios.get(url, { headers });
return response.data;
} catch (error) {
console.error(`获取失败: ${error.message}`);
@@ -44,28 +60,28 @@ async function getSwaggerJson(group) {
function generateModuleDoc(group, tag, paths) {
const moduleName = tag.name;
let markdown = `---
title: ${moduleName} API
description: ${tag.description || `${moduleName} 相关接口`}
:title: ${moduleName} API
:description: ${tag.description || `${moduleName} 相关接口`}
---
# ${moduleName} API
::: info ${moduleName}
:::: info ${moduleName}
${tag.description || `${moduleName} 相关接口`}
:::
::::
## API 列表
`;
// 查找该标签下的接口
Object.keys(paths).forEach(path => {
Object.keys(paths[path]).forEach(method => {
const operation = paths[path][method];
Object.keys(paths).forEach((p) => {
Object.keys(paths[p]).forEach((method) => {
const operation = paths[p][method];
if (operation.tags && operation.tags.includes(tag.name)) {
markdown += generateApiDoc(path, method, operation);
markdown += generateApiDoc(p, method, operation);
}
});
});
@@ -76,11 +92,11 @@ ${tag.description || `${moduleName} 相关接口`}
/**
* 生成单个 API 文档
*/
function generateApiDoc(path, method, operation) {
function generateApiDoc(pathname, method, operation) {
const summary = operation.summary || operation.operationId || '未命名接口';
let doc = `### ${summary}\n\n`;
doc += `**接口**: \`${method.toUpperCase()} ${path}\`\n\n`;
doc += `**接口**: \`${method.toUpperCase()} ${pathname}\`\n\n`;
if (operation.description) {
doc += `**描述**: ${operation.description}\n\n`;
@@ -92,7 +108,7 @@ function generateApiDoc(path, method, operation) {
doc += `| 参数 | 类型 | 必填 | 说明 |\n`;
doc += `|------|------|------|------|\n`;
operation.parameters.forEach(param => {
operation.parameters.forEach((param) => {
const required = param.required ? '是' : '否';
const type = param.type || param.schema?.type || 'string';
doc += `| ${param.name} | ${type} | ${required} | ${param.description || ''} |\n`;
@@ -104,22 +120,14 @@ function generateApiDoc(path, method, operation) {
if (operation.requestBody) {
doc += `**请求体**:\n\n`;
doc += `\`\`\`json\n`;
doc += `{\n`;
doc += ` // 请求数据\n`;
doc += `}\n`;
doc += `{}\n`;
doc += `\`\`\`\n\n`;
}
// 响应
doc += `**响应**:\n\n`;
doc += `\`\`\`json\n`;
doc += `{\n`;
doc += ` "code": 200,\n`;
doc += ` "message": "success",\n`;
doc += ` "data": {\n`;
doc += ` // 响应数据\n`;
doc += ` }\n`;
doc += `}\n`;
doc += `{"code":200,"message":"success","data":{}}\n`;
doc += `\`\`\`\n\n`;
doc += `---\n\n`;
@@ -148,7 +156,7 @@ function saveDocument(outputPath, content) {
async function main() {
console.log('开始自动生成 API 文档...\n');
for (const [key, group] of Object.entries(config.apiGroups)) {
for (const [_key, group] of Object.entries(config.apiGroups)) {
console.log(`处理 ${group.name}...`);
const swaggerData = await getSwaggerJson(group);
@@ -164,7 +172,11 @@ async function main() {
for (const tag of tags) {
const moduleName = tag.name.toLowerCase().replace(/\s+/g, '-');
const content = generateModuleDoc(group, tag, paths);
const outputPath = path.join(config.docsDir, group.outputDir, `${moduleName}.md`);
const outputPath = path.join(
config.docsDir,
group.outputDir,
`${moduleName}.md`,
);
saveDocument(outputPath, content);
}
@@ -177,7 +189,7 @@ async function main() {
// 运行
if (require.main === module) {
main().catch(error => {
main().catch((error) => {
console.error('执行失败:', error);
process.exit(1);
});

View File

@@ -1,276 +1,218 @@
# 前后端智能体协调配置
# 前后端智能体协调机制
## 协调原则
### 1. 同步开发原则
- 前后端智能体同时启动,并行开发
- 通过 API 契约进行协调
- 定期同步开发进度
- **并行开发**: 前后端智能体并行工作,通过契约接口协调
- **契约优先**: 优先定义 API 契约,确保前后端接口一致
- **质量对等**: 前后端质量要求保持一致,测试覆盖率对等
### 2. 契约优先原则
- 先定义 API 接口契约
- 前后端基于契约独立开发
- 契约变更需要双方确认
### 2. 规范对齐原则
- **命名对齐**: 前后端命名规范保持一致,优先使用业务术语
- **结构对齐**: 前后端数据结构保持一致DTO 与前端类型对应
- **错误对齐**: 前后端错误处理机制保持一致,错误码统一
### 3. 质量对等原则
- 前后端质量要求一致
- 测试覆盖率要求相同
- 代码规范标准统一
### 3. 工具协调原则
- **版本控制**: 使用 Git 进行版本控制,前后端代码分离管理
- **CI/CD 协调**: 前后端构建流程协调,确保部署一致性
- **文档同步**: API 文档与前端类型定义同步更新
## 智能体映射关系
| 前端智能体 | 后端智能体 | 协调内容 | 协调时机 |
| 前端智能体 | 后端智能体 | 协调阶段 | 主要职责 |
|-----------|-----------|----------|----------|
| F1 FrontendAnalyzer | S1 Analyzer | 需求分析、API 设计 | 项目启动 |
| F2 FrontendArchitect | S2 Architect | 架构设计、接口定义 | 架构设计阶段 |
| F3 FrontendInfraOperator | S3 InfraOperator | 基建接入、环境配置 | 基建搭建阶段 |
| F4 FrontendDeveloper | S4 Developer | 功能开发、接口联调 | 开发阶段 |
| F5 FrontendSecurityGuard | S5 SecurityGuard | 安全检查、权限控制 | 开发中/提测前 |
| F6 FrontendQualityGate | S6 QualityGate | 质量检查、测试覆盖 | CI/CD 阶段 |
| F7 FrontendAuditor | S7 Auditor | 规范审计、代码质量 | 提测前 |
| F8 FrontendRelease | S8 Release | 构建部署版本管理 | 发布阶段 |
| F9 FrontendPerfTuner | S9 PerfTuner | 性能优化、监控指标 | 持续优化 |
| F1 FrontendAnalyzer | S1 Analyzer | 需求分析 | 页面设计与接口设计协调 |
| F2 FrontendArchitect | S2 Architect | 架构设计 | 整体架构与目录结构协调 |
| F3 FrontendInfraOperator | S3 InfraOperator | 基建接入 | 开发环境与工具链协调 |
| F4 FrontendDeveloper | S4 Developer | 功能开发 | 接口实现与页面开发协调 |
| F5 FrontendSecurityGuard | S5 SecurityGuard | 安全检查 | 前后端安全策略协调 |
| F6 FrontendQualityGate | S6 QualityGate | 质量门禁 | 代码质量与测试协调 |
| F7 FrontendAuditor | S7 Auditor | 规范审计 | 代码规范与标准协调 |
| F8 FrontendRelease | S8 Release | 发布部署 | 构建部署版本协调 |
| F9 FrontendPerfTuner | S9 PerfTuner | 性能优化 | 性能指标与优化协调 |
## 协调检查点
### 检查点 1项目启动F1 + S1
```yaml
协调内容:
- 业务需求理解
- 技术栈确认
- 开发计划制定
- API 接口规划
### 1. 项目启动阶段
**参与智能体**: F1 + S1
**协调内容**:
- 业务需求分析与技术方案设计
- 页面功能划分与 API 接口设计
- 开发计划制定与里程碑设定
输出产物:
- 需求文档
- 技术方案
- API 接口文档
- 开发计划
```
**输出产物**:
- 需求分析文档
- API 接口设计文档
- 开发计划与时间安排
### 检查点 2架构设计F2 + S2
```yaml
协调内容:
- 系统架构设计
- 数据模型设计
- 组件结构设计
- 接口定义
### 2. 架构设计阶段
**参与智能体**: F2 + S2
**协调内容**:
- 整体架构设计与技术选型
- 目录结构设计与模块划分
- 数据流设计与状态管理方案
输出产物:
- 架构设计文档
- 数据模型文档
- 组件设计文档
- API 接口定义
```
**输出产物**:
- 架构设计文档
- 目录结构规范
- 数据流设计文档
### 检查点 3基建搭建F3 + S3
```yaml
协调内容:
- 开发环境配置
- 工具链搭建
- 基础组件开发
- 数据库设计
### 3. 基建接入阶段
**参与智能体**: F3 + S3
**协调内容**:
- 开发环境配置与工具链搭建
- 依赖管理策略与版本控制
- 构建流程设计与自动化配置
输出产物:
- 开发环境配置
- 工具链配置
- 基础组件库
- 数据库结构
```
**输出产物**:
- 开发环境配置文档
- 工具链使用指南
- 构建流程文档
### 检查点 4功能开发F4 + S4
```yaml
协调内容:
- API 接口开发
- 前端页面开发
- 接口联调
- 功能测试
### 4. 功能开发阶段
**参与智能体**: F4 + S4
**协调内容**:
- API 接口实现与前端页面开发
- 数据交互逻辑与状态管理
- 业务逻辑实现与用户体验
输出产物:
- API 接口实现
- 前端页面实现
- 联调测试报告
- 功能测试报告
```
**输出产物**:
- 功能模块代码
- API 接口文档
- 测试用例与测试报告
### 检查点 5质量保障F5-F7 + S5-S7
```yaml
协调内容:
- 安全检查
- 代码质量检查
- 测试覆盖率
- 规范审计
### 5. 质量保证阶段
**参与智能体**: F5 + S5, F6 + S6
**协调内容**:
- 安全策略实施与漏洞修复
- 代码质量检查与测试覆盖
- 性能指标监控与优化
输出产物:
- 安全测试报告
- 代码质量报告
- 测试覆盖率报告
- 规范审计报告
```
**输出产物**:
- 安全评估报告
- 质量检查报告
- 性能测试报告
### 检查点 6部署上线F8 + S8
```yaml
协调内容:
- 构建部署
- 版本管理
- 环境配置
- 监控配置
### 6. 规范审计阶段
**参与智能体**: F7 + S7
**协调内容**:
- 代码规范检查与标准对齐
- 最佳实践实施与文档完善
- 技术债务识别与重构计划
输出产物:
- 构建产物
- 部署配置
- 版本说明
- 监控配置
```
**输出产物**:
- 规范检查报告
- 最佳实践文档
- 重构计划与建议
### 检查点 7性能优化F9 + S9
```yaml
协调内容:
- 性能分析
- 优化方案
- 监控指标
- 持续优化
### 7. 发布部署阶段
**参与智能体**: F8 + S8
**协调内容**:
- 构建流程协调与版本管理
- 部署策略制定与环境配置
- 发布计划执行与回滚预案
输出产物:
- 性能分析报告
- 优化方案
- 监控指标
- 优化效果报告
```
**输出产物**:
- 构建产物与部署包
- 部署配置文档
- 发布计划与回滚预案
## 协调工具
### 1. API 契约管理
```yaml
工具: OpenAPI/Swagger
用途: API 接口定义和文档
协调: 前后端基于契约开发
```
- **OpenAPI/Swagger**: API 接口文档与类型定义
- **TypeScript 类型生成**: 前端类型定义自动生成
- **API 测试工具**: 接口测试与验证
### 2. 代码仓库管理
```yaml
工具: Git + GitLab/GitHub
用途: 代码版本管理
协调: 分支策略、合并策略
```
### 2. 版本控制
- **Git**: 代码版本控制与分支管理
- **GitHub/GitLab**: 代码托管与协作平台
- **Git Flow**: 分支策略与发布流程
### 3. 持续集成/部署
```yaml
工具: CI/CD Pipeline
用途: 自动化构建、测试、部署
协调: 构建流程、测试流程
```
### 3. CI/CD 协调
- **GitHub Actions/GitLab CI**: 自动化构建与测试
- **Docker**: 容器化部署与环境一致性
- **Kubernetes**: 容器编排与服务管理
### 4. 项目管理
```yaml
工具: Jira/禅道
用途: 任务管理、进度跟踪
协调: 任务分配、进度同步
```
- **Jira/ZenTao**: 需求管理与任务跟踪
- **Confluence/Notion**: 文档管理与知识共享
- **Slack/钉钉**: 即时沟通与通知
### 5. 沟通协作
```yaml
工具: 企业微信/钉钉
用途: 实时沟通、问题讨论
协调: 日常沟通、问题解决
```
### 5. 监控与反馈
- **Sentry**: 错误监控与性能追踪
- **Prometheus**: 指标监控与告警
- **Grafana**: 数据可视化与报表
## 协调流程
### 1. 项目启动阶段
### 1. 日常开发协调
```
1. 需求分析会议F1 + S1
2. 技术方案评审F2 + S2
3. 开发计划制定
4. API 接口设计
每日站会 → 任务分配 → 并行开发 → 代码审查 → 集成测试 → 部署验证
```
### 2. 开发阶段
### 2. 版本发布协调
```
1. 基建搭建F3 + S3
2. 功能开发F4 + S4
3. 接口联调
4. 功能测试
需求冻结 → 功能开发 → 集成测试 → 预发布验证 → 正式发布 → 监控反馈
```
### 3. 质量保障阶段
### 3. 问题处理协调
```
1. 安全检查F5 + S5
2. 质量检查F6 + S6
3. 规范审计F7 + S7
4. 问题修复
```
### 4. 部署上线阶段
```
1. 构建部署F8 + S8
2. 环境验证
3. 性能优化F9 + S9
4. 监控配置
问题发现 → 影响评估 → 方案制定 → 并行修复 → 验证测试 → 部署上线
```
## 协调规范
### 1. 沟通规范
- **定期会议**:每周一次协调会议
- **即时沟通**:重要问题即时沟通
- **文档记录**:重要决策文档记录
- **问题跟踪**:问题跟踪和解决
- **定期同步**: 每日站会、周例会、里程碑评审
- **异步沟通**: 使用文档、评论、邮件进行异步沟通
- **紧急沟通**: 使用即时通讯工具进行紧急问题处理
### 2. 代码规范
- **代码风格**:统一的代码风格
- **命名规范**:统一的命名规范
- **注释规范**:统一的注释规范
- **提交规范**:统一的提交规范
### 2. 文档规范
- **API 文档**: 使用 OpenAPI 规范,及时更新
- **技术文档**: 使用 Markdown 格式,结构清晰
- **变更日志**: 记录所有重要变更,便于追溯
### 3. 测试规范
- **测试覆盖**:统一的测试覆盖率要求
- **测试类型**单元测试、集成测试、e2e 测试
- **测试环境**:统一的测试环境
- **测试报告**:统一的测试报告格式
### 3. 代码规范
- **命名规范**: 前后端命名保持一致,使用业务术语
- **注释规范**: 关键逻辑必须有注释,便于理解
- **提交规范**: 使用规范的提交信息,便于版本管理
### 4. 部署规范
- **环境管理**:开发、测试、生产环境
- **版本管理**:统一的版本管理策略
- **回滚策略**:统一的回滚策略
- **监控告警**:统一的监控告警策略
### 4. 测试规范
- **单元测试**: 前后端都要有充分的单元测试
- **集成测试**: 前后端集成测试,确保接口正确
- **端到端测试**: 完整的用户流程测试
## 协调效果评估
## 效果评估
### 1. 开发效率
- **开发周期**:项目开发周期
- **代码质量**:代码质量指标
- **测试覆盖**:测试覆盖率
- **问题数量**:问题数量和解决时间
### 1. 开发效率指标
- **开发周期**: 从需求到上线的完整周期
- **代码质量**: 缺陷密度、技术债务比例
- **团队协作**: 沟通效率、冲突解决时间
### 2. 协作效果
- **沟通效率**:沟通效率和效果
- **协调成本**:协调所需的时间和成本
- **冲突解决**:冲突解决的速度和质量
- **团队满意度**:团队满意度调查
### 2. 产品质量指标
- **功能完整性**: 需求实现程度、功能覆盖率
- **性能指标**: 响应时间、吞吐量、资源使用
- **用户体验**: 用户满意度、易用性评分
### 3. 项目质量
- **功能完整性**:功能实现完整性
- **性能指标**:系统性能指标
- **用户体验**:用户体验评价
- **稳定性**:系统稳定性指标
### 3. 运维指标
- **部署频率**: 发布频率、部署成功率
- **系统稳定性**: 可用性、故障恢复时间
- **监控覆盖**: 监控覆盖率、告警准确性
## 持续改进
### 1. 定期回顾
- **项目回顾**:项目结束后进行回顾
- **流程优化**:根据回顾结果优化流程
- **工具改进**:根据使用情况改进工具
- **规范更新**:根据实践情况更新规范
- **回顾**: 每周进行开发回顾,识别改进点
- **月回顾**: 每月进行项目回顾,评估整体效果
- **季度回顾**: 每季度进行战略回顾,调整方向
### 2. 经验总结
- **最佳实践**:总结最佳实践
- **问题案例**:总结问题案例和解决方案
- **工具推荐**:推荐新的工具和方法
- **培训计划**:制定培训计划
### 2. 改进措施
- **流程优化**: 根据回顾结果优化协调流程
- **工具升级**: 引入新的工具提升协作效率
- **技能提升**: 团队技能培训与知识分享
### 3. 技术演进
- **技术更新**:跟进技术更新
- **架构演进**:架构演进规划
- **工具升级**:工具升级计划
- **标准更新**:标准更新计划
### 3. 最佳实践
- **经验总结**: 总结成功经验,形成最佳实践
- **案例分享**: 分享典型案例,促进团队学习
- **标准制定**: 制定团队标准,确保一致性

View File

@@ -1,201 +1,425 @@
# 前端多智能体工作流Ant Design Vue
# 前端多智能体工作流 (F1-F9)
## 智能体角色定义(按执行顺序标注)
### F1 前端需求分析体(FrontendAnalyzer)
- **职责**解析前端需求、对应 Ant Design Vue 规范、输出组件设计与页面结构
- **输入**业务需求、UI/UX 设计稿、后端 API 接口
- **输出**:页面结构图、组件、路由配置、状态管理方案
### F1 前端分析体(FrontendAnalyzer)
- **职责**: 解析前端需求、对应 Vben Admin 规范、输出页面结构与组件设计
- **输入**: 业务需求、UI设计稿、后端API接口
- **输出**: 页面划分、组件设计、路由配置、状态管理方案
- **必做检查M1 前置)**:
- 先阅读 `src/views/demos/` 中的示例,遵循演示的交互与代码风格
- 严禁修改 `src/views/demos/` 下任何代码(仅可参考)
- 开发与改动范围仅限 `admin/apps/web-antd/src` 目录
- **与 PHP 对齐admin/site 双端)**:
- 明确模块是否在「管理端admin」还是「站点端site」呈现与操作
- 功能“真值”以 PHP 页面为准:字段/交互/校验/约束先梳理清单,再对照 Vben 风格实现
### F2 前端架构治理体(FrontendArchitect)
- **职责**:校验前端分层/目录规范,给出组件设计建议与边界清单
- **校验**:目录结构、组件分层、状态管理、路由设计
- **输出**:前端架构设计说明、组件接口定义、重构建议
### F2 前端架构体(FrontendArchitect)
- **职责**: 校验目录结构、组件分层、依赖关系,给出架构建议
- **输入**: 页面划分、组件设计
- **输出**: 目录结构、组件分层、依赖方向、删除/迁移建议
- **约束补充**:
- 页面骨架需与 demos 的交互与布局风格一致Page + Grid/Toolbar + Drawer/Modal
- 模块化目录与命名严格遵循 `src/views/{module}/` 规范
- 区分 admin/site 的路由前缀与菜单归属;前端路由命名与 PHP 端保持语义一致
### F3 前端基建接入体(FrontendInfraOperator)
- **职责**接入/校验 Vben Admin 生态、状态管理、路由、国际化
- **入**Pinia、Vue Router、i18n、Vben Hooks、组件库适配
- **产物**接入差异与示例代码,配置项校验清单
### F3 前端基建体(FrontendInfraOperator)
- **职责**: 接入/校验 Vben Admin 生态、状态管理、路由配置
- **入**: 架构设计、依赖需求
- **输出**: 基建配置、接入差异与示例代码
- **规范工具(必须启动)**:
- 启动开发(基础校验随 Vite Dev 一起运行)
- pnpm -F @vben/web-antd run dev
- 持续类型检查(推荐独立终端常驻)
- pnpm -F @vben/web-antd run typecheck --watch
- 首轮规范修复(确保本地无明显规范问题再进入开发)
- pnpm -F @vben/web-antd run lint --fix
- pnpm -F @vben/web-antd run stylelint --fix
- pnpm -F @vben/web-antd run format
- **API 契约与映射策略**:
- 与后端 OpenAPI/Swagger 契约对齐;区分 admin `/adminapi` 与 site `/api`
- 若后端字段为 `snake_case`,前端统一 camelCase映射集中在 API 层(不要在页面里散落转换)
### F4 前端开发执行体(FrontendDeveloper)
- **职责**按规范编码、编写组件、修复构建
- **实现**页面组件、业务逻辑、API 调用、状态管理
- **测试**:组件测试、页面测试、集成测试
### F4 前端开发体(FrontendDeveloper)
- **职责**: 按规范编码、编写测试、修复构建
- **输入**: 页面设计、组件设计、API接口
- **输出**: 页面组件、业务逻辑、单元测试、构建通过
- **UI 交互约定**:
- 简单单页/单步表单:可使用 Modal或在 Drawer 内的单面板)
- 多 Tab/多步骤/复杂设置表单:统一使用 Drawer抽屉承载
- 操作列统一使用 `CellOperation`;状态开关统一使用 `CellSwitch/CellTag`
- 页面框架统一 `Page + Grid`,工具栏使用 `#toolbar-tools` 槽位
- **用户管理改造admin/site 双端)**:
- 列表/筛选id、用户名、状态、备注、创建时间对齐 PHP 字段;前端统一 camelCase
- 基础操作:新建/编辑/删除、启用/禁用(支持批量)
- 账号能力:重置密码、锁定/解锁(若 PHP 存在则对齐)
- 权限归属分配角色role、部门选择dept-tree
- 资料与上传头像上传、基础资料字段email/mobile/avatar 等)
- 详情与表单:多 Tab 表单使用 Drawer成功后刷新 `gridApi.query()`
- 路由与菜单:指向 admin 端;若 site 端也有用户自管页面,建立对应 site 路由并复用组件
### F5 前端安全基线体(FrontendSecurityGuard)
- **职责**检查权限控制、数据验证、XSS 防护
- **检查**:路由守卫、按钮权限、表单验证、敏感信息处理
### F5 前端安全体(FrontendSecurityGuard)
- **职责**: 检查权限控制、敏感信息暴露、XSS防护
- **输入**: 页面组件、权限配置
- **输出**: 安全检查报告、修复建议
- **对齐要求**:
- 所有管理端页面需具备权限码与菜单守卫;敏感操作需二次确认
### F6 前端质量门禁体(FrontendQualityGate)
- **职责**聚合 ESLint/TS/覆盖率/e2e 结果,低于阈值阻断合并
- **指标**ESLint/TS 无报错、覆盖率≥阈值、e2e 关键路径通过
### F6 前端质量体(FrontendQualityGate)
- **职责**: 聚合 ESLint/TS/覆盖率/e2e 结果,低于阈值阻断合并
- **输入**: 代码质量指标
- **输出**: 质量报告、阻断/通过决策
- **规范工具校验(必过项)**:
- pnpm -F @vben/web-antd run typecheck
- pnpm -F @vben/web-antd run lint
- pnpm -F @vben/web-antd run stylelint
- pnpm -F @vben/web-antd run test (含 unit/component)
- pnpm -F @vben/web-antd run e2e关键路径
- **一致性核查**:
-`src/views/demos` 风格逐项对齐;与 PHP 页面功能逐项对齐(以核对清单为准)
### F7 前端规范审计体(FrontendAuditor)
- **职责**按清单逐项核查,出具差异报告与修复项
- **检查**:组件规范、命名规范、代码风格、性能优化
### F7 前端审计体(FrontendAuditor)
- **职责**: 按清单逐项核查,出具差异报告与修复项
- **输入**: 代码规范、Vben Admin 标准
- **输出**: 规范检查报告、修复任务清单
### F8 前端构建部署体(FrontendRelease)
- **职责**构建、变更说明、部署计划
- **产出**构建产物、变更日志、部署步骤
### F8 前端发布体(FrontendRelease)
- **职责**: 构建、变更说明、部署计划
- **输入**: 构建产物、变更日志
- **输出**: 部署包、变更说明、部署步骤
### F9 前端性能优化体(FrontendPerfTuner)
- **职责**建议缓存/懒加载/代码分割,识别性能瓶颈
- **优化**:组件懒加载、图片优化、包体积优化、渲染性能
### F9 前端优化体(FrontendPerfTuner)
- **职责**: 建议缓存懒加载代码分割,识别性能瓶颈
- **输入**: 性能指标、用户体验
- **输出**: 优化建议、性能报告
## 前后端智能体协调机制
## 串联流程(带顺序)
### 协调流程F1-F9 + S1-S9
### 1) F1 FrontendAnalyzer
- **输入**: 业务需求/UI设计稿/后端API接口
- **输出**: 页面划分、组件设计、路由配置、状态管理方案
- **注意**: 先阅读 `src/views/demos/`,严禁修改 demos 代码,所有开发仅在 `src` 范围内完成
- **PHP 对齐**: 先列出 admin/site 双端的用户管理差异清单(字段/交互/入口)
#### 阶段一需求分析与架构设计F1+F2 + S1+S2
```
F1 FrontendAnalyzer ←→ S1 Analyzer
├── 前端需求分析 ←→ 后端 API 设计
├── 组件结构设计 ←→ 数据模型设计
└── 状态管理方案 ←→ 业务逻辑设计
### 2) F2 FrontendArchitect
- **校验**: 目录结构、组件分层、依赖方向
- **输出**: 目录结构设计、组件分层方案、删除/迁移建议
- **PHP 对齐**: 确认 admin 与 site 两端的路由/菜单归属与复用边界
F2 FrontendArchitect ←→ S2 Architect
├── 前端架构设计 ←→ 后端架构设计
├── 组件接口定义 ←→ API 接口定义
└── 数据流设计 ←→ 数据流设计
```
### 3) F3 FrontendInfraOperator
- **接入**: Vben Admin 生态、状态管理、路由配置
- **产物**: 基建配置、接入差异与示例代码
- **并行启动规范工具**:
- dev + typecheck --watch两个终端常驻
- lint/stylelint/format 首轮修复
- **API 契约**: 对齐 admin `/adminapi` 与 site `/api`,前端统一 camelCase映射在 API 层
#### 阶段二基建接入与开发F3+F4 + S3+S4
```
F3 FrontendInfraOperator ←→ S3 InfraOperator
├── 前端基建接入 ←→ 后端基建接入
├── 组件库适配 ←→ 数据库/缓存接入
└── 开发环境配置 ←→ 开发环境配置
### 4) F4 FrontendDeveloper
- **实现**: 页面组件、业务逻辑、状态管理、路由配置
- **测试**: 单元测试、组件测试、e2e测试
- **构建**: 确保构建通过
- **UI 交互约定**: 多 Tab 表单用 Drawer简单表单可用 Modal
F4 FrontendDeveloper ←→ S4 Developer
├── 前端组件开发 ←→ 后端接口开发
├── API 调用实现 ←→ API 接口实现
└── 页面功能开发 ←→ 业务逻辑实现
```
### 5) F5 FrontendSecurityGuard第一次开发阶段
- **检查**: 权限控制、敏感信息暴露、XSS防护
#### 阶段三质量保障与部署F5-F9 + S5-S9
```
F5 FrontendSecurityGuard ←→ S5 SecurityGuard
├── 前端安全检查 ←→ 后端安全检查
├── 权限控制实现 ←→ 权限控制实现
└── 数据验证 ←→ 数据验证
### 6) F6 FrontendQualityGateCI 阶段
- **指标**: ESLint/TS 无报错覆盖率≥80%e2e 关键路径通过
- **动作**: 不达标阻断合并
- **执行**: typecheck/lint/stylelint/test/e2e 均需通过;对照 PHP 页面核对功能完整性
F6 FrontendQualityGate ←→ S6 QualityGate
├── 前端质量检查 ←→ 后端质量检查
├── 代码规范检查 ←→ 代码规范检查
└── 测试覆盖率 ←→ 测试覆盖率
### 7) F7 FrontendAuditor提测前
- **检查**: 规范清单、Vben Admin 标准对齐
- **产物**: 差异报告与修复任务
F7 FrontendAuditor ←→ S7 Auditor
├── 前端规范审计 ←→ 后端规范审计
├── 代码质量检查 ←→ 代码质量检查
└── 架构合规性 ←→ 架构合规性
### 8) F5 FrontendSecurityGuard第二次提测前
- **复检**: 重要页面的权限控制、安全防护
F8 FrontendRelease ←→ S8 Release
├── 前端构建部署 ←→ 后端构建部署
├── 版本管理 ←→ 版本管理
└── 部署协调 ←→ 部署协调
### 9) F9 FrontendPerfTuner并行/持续)
- **建议**: 缓存、懒加载、代码分割、性能优化
F9 FrontendPerfTuner ←→ S9 PerfTuner
├── 前端性能优化 ←→ 后端性能优化
├── 用户体验优化 ←→ 系统性能优化
└── 监控指标 ←→ 监控指标
```
### 10) F8 FrontendRelease
- **产出**: 构建产物、变更日志、部署步骤
## 前端开发规范
### 目录结构规范
```
src/
├── views/ # 页面组件
│ ├── system/ # 系统管理模块
│ ├── business/ # 业务模块
└── _core/ # 核心页面
├── components/ # 公共组件
├── api/ # API 接口
├── store/ # 状态管理
├── router/ # 路由配置
├── locales/ # 国际化
├── utils/ # 工具函数
└── adapter/ # 适配器层
├── views/ # 页面目录
│ ├── {module}/ # 功能模块目录(语义化命名)
│ ├── index.vue # 主页面(必须使用 Page 组件)
│ ├── data.ts # 数据配置
│ │ └── modules/ # 子组件目录
├── form.vue # 表单组件
│ │ ├── table.vue # 表格组件
└── ...
│ └── demos/ # 示例页面目录
│ ├── {feature}/ # 功能特性目录
│ │ ├── index.vue # 主页面
│ │ └── {sub}.vue # 子页面
│ └── ...
├── api/ # API 接口目录
│ ├── {module}/ # 按模块组织
│ │ ├── index.ts # 模块 API 入口
│ │ └── {feature}.ts # 具体功能 API
│ └── index.ts # API 总入口
├── router/ # 路由配置
│ ├── routes/ # 路由定义
│ │ └── modules/ # 按模块组织
│ └── index.ts # 路由入口
├── locales/ # 多语言
│ └── langs/ # 语言包
├── adapter/ # 适配器
│ ├── form.ts # 表单适配器
│ └── vxe-table.ts # 表格适配器
└── __tests__/ # 测试目录
├── e2e/ # 端到端测试
├── unit/ # 单元测试
│ ├── components/ # 组件测试
│ ├── views/ # 页面测试
│ └── api/ # API 测试
└── integration/ # 集成测试
```
### 组件开发规范
- **命名规范**PascalCase语义化命名
- **文件结构**index.vue + data.ts + modules/
- **组件设计**:单一职责、可复用、可测试
- **状态管理**:使用 Pinia按模块划分
### 页面组件规范
### API 调用规范
- **接口定义**:使用 TypeScript 类型定义
- **错误处理**:统一错误处理机制
- **请求封装**:使用 Vben 的 request 工具
- **数据转换**:前后端数据格式转换
#### 主页面结构(必须使用 Page 组件)
```vue
<template>
<Page title="页面标题" description="页面描述">
<Card title="卡片标题">
<!-- 页面内容 -->
</Card>
</Page>
</template>
### 路由配置规范
- **路由结构**:按模块组织,支持懒加载
- **权限控制**:路由级权限控制
- **面包屑**:自动生成面包屑导航
- **缓存策略**:页面缓存配置
<script lang="ts" setup>
// 页面逻辑
</script>
```
## 前后端协调检查点
#### 子页面结构
```vue
<template>
<Fallback status="coming-soon" />
</template>
### 开发前协调
- [ ] API 接口设计确认
- [ ] 数据模型对齐
- [ ] 权限控制方案
- [ ] 错误处理机制
<script lang="ts" setup>
// 子页面逻辑
</script>
```
### 开发中协调
- [ ] API 接口联调
- [ ] 数据格式验证
- [ ] 权限控制测试
- [ ] 错误处理测试
#### 表单组件结构
```vue
<template>
<Form :schema="schema" @submit="handleSubmit">
<!-- 表单内容 -->
</Form>
</template>
### 开发后协调
- [ ] 功能完整性测试
- [ ] 性能测试
- [ ] 安全测试
- [ ] 部署协调
<script lang="ts" setup>
// 表单逻辑
</script>
```
## 工具与配置
### 命名规范
#### 文件命名
- **主页面**: 必须命名为 `index.vue`
- **子页面**: 使用 kebab-case`button-control.vue`
- **组件文件**: 使用 kebab-case`form.vue``table.vue`
- **API 文件**: 使用 kebab-case`role.ts``menu.ts`
#### 目录命名
- **功能模块**: 使用语义化命名,如 `system``user``order`
- **子目录**: 使用语义化命名,如 `modules``components`
#### 组件命名
- **页面组件**: 使用 PascalCase`RoleList``MenuForm`
- **业务组件**: 使用 PascalCase`UserTable``OrderForm`
### 测试规范
#### 测试目录结构
```
__tests__/
├── e2e/ # 端到端测试
│ └── {feature}.spec.ts # 功能测试
├── unit/ # 单元测试
│ ├── components/ # 组件测试
│ │ └── {component}.spec.ts
│ ├── views/ # 页面测试
│ │ └── {page}.spec.ts
│ └── api/ # API 测试
│ └── {api}.spec.ts
└── integration/ # 集成测试
└── {feature}.spec.ts
```
#### 测试覆盖率要求
- **单元测试覆盖率**: ≥ 80%
- **组件测试覆盖率**: ≥ 90%
- **e2e 测试覆盖率**: 关键路径 100%
### 代码质量规范
#### ESLint 规范
- 必须通过 ESLint 检查
- 禁止使用 `any` 类型
- 必须使用 TypeScript 严格模式
#### TypeScript 规范
- 必须通过类型检查
- 必须定义接口和类型
- 禁止使用 `@ts-ignore`
#### 组件规范
- 必须使用 `<script lang="ts" setup>`
- 必须定义 Props 和 Emits 类型
- 必须使用 Vben Admin 组件库
### 状态管理规范
#### Pinia Store 结构
```typescript
// stores/{module}.ts
export const use{Module}Store = defineStore('{module}', {
state: () => ({
// 状态定义
}),
getters: {
// 计算属性
},
actions: {
// 操作方法
},
});
```
#### API 调用规范
```typescript
// api/{module}/{feature}.ts
export function get{Feature}List(params: Get{Feature}ListParams) {
return defHttp.get<{Feature}ListResult>({
url: '/api/{module}/{feature}',
params,
});
}
```
### 路由规范
#### 路由配置结构
```typescript
// router/routes/modules/{module}.ts
export default {
path: '/{module}',
name: '{Module}',
component: () => import('@/layouts/default.vue'),
meta: {
title: '{module}.title',
icon: '{icon}',
},
children: [
{
path: '{feature}',
name: '{Module}{Feature}',
component: () => import('@/views/{module}/{feature}/index.vue'),
meta: {
title: '{module}.{feature}.title',
},
},
],
};
```
### 多语言规范
#### 语言包结构
```json
// locales/langs/zh-CN/{module}.json
{
"{module}": {
"title": "模块标题",
"{feature}": {
"title": "功能标题",
"description": "功能描述"
}
}
}
```
### 构建规范
#### 构建要求
- 必须通过 TypeScript 编译
- 必须通过 ESLint 检查
- 必须通过单元测试
- 必须通过 e2e 测试
#### 性能要求
- 首屏加载时间 ≤ 2s
- 页面切换时间 ≤ 500ms
- 包体积 ≤ 2MB
## 关键约束
### 页面结构约束
- **主页面必须使用 Page 组件**: 包含 `title``description`
- **子组件必须放在 modules 目录**: 保持目录结构清晰
- **数据配置使用 data.ts**: 分离数据和逻辑
### 测试约束
- **单元测试必须放在 `__tests__/unit/` 目录**: 按功能模块组织
- **测试覆盖率必须 ≥ 80%**: 确保代码质量
- **每个组件必须有测试文件**: 保证测试完整性
### 命名规范约束
- **主页面文件必须命名为 `index.vue`**: 统一命名规范
- **文件命名使用 kebab-case**: 保持一致性
- **目录命名使用语义化命名**: 便于理解
### 代码质量约束
- **ESLint 必须通过**: 确保代码规范
- **TypeScript 类型检查必须通过**: 保证类型安全
- **代码规范必须统一**: 保持团队一致性
### 性能约束
- **必须使用懒加载**: 优化首屏加载
- **必须使用代码分割**: 减少包体积
- **必须使用缓存策略**: 提升用户体验
## 与后端协调
### 协调原则
- **API 接口对齐**: 前后端 API 接口必须一致
- **数据类型对齐**: 前后端数据类型必须匹配
- **错误处理对齐**: 前后端错误处理必须统一
### 协调检查点
- **需求分析阶段**: F1 与 S1 协调页面和接口设计
- **架构设计阶段**: F2 与 S2 协调整体架构
- **开发阶段**: F4 与 S4 协调接口实现
- **测试阶段**: F6 与 S6 协调测试用例
- **发布阶段**: F8 与 S8 协调部署计划
## 工具偏好
### 开发工具
- **IDE**VS Code + Volar
- **调试**Vue DevTools
- **测试**Vitest + Playwright
- **构建**Vite
- **IDE**: VS Code + Volar
- **包管理**: pnpm
- **构建工具**: Vite
- **测试工具**: Vitest + Playwright
### 代码规范
- **ESLint**:代码质量检查
- **Prettier**代码格式化
- **TypeScript**类型检查
- **Husky**Git 钩子
### 代码质量工具
- **ESLint**: 代码规范检查
- **Prettier**: 代码格式化
- **TypeScript**: 类型检查
- **Husky**: Git hooks
### 性能监控
- **Bundle Analyzer**:包体积分析
- **Lighthouse**:性能评分
- **Web Vitals**核心指标监控
## 执行与验收
### 开发流程
1. **需求分析**F1 分析需求,与 S1 协调
2. **架构设计**F2 设计架构,与 S2 协调
3. **基建接入**F3 接入基建,与 S3 协调
4. **功能开发**F4 开发功能,与 S4 协调
5. **质量保障**F5-F7 质量检查,与 S5-S7 协调
6. **部署上线**F8 构建部署,与 S8 协调
7. **性能优化**F9 性能优化,与 S9 协调
### 验收标准
- **功能完整性**:所有功能正常
- **性能指标**:加载时间、响应时间达标
- **代码质量**ESLint 通过、测试覆盖率高
- **用户体验**:界面美观、交互流畅
- **兼容性**:多浏览器兼容
- **安全性**:无安全漏洞
### 持续改进
- **代码审查**:定期代码审查
- **性能监控**:持续性能监控
- **用户反馈**:收集用户反馈
- **技术更新**:跟进技术更新
### 性能监控工具
- **Lighthouse**: 性能分析
- **Bundle Analyzer**: 包体积分析
- **Web Vitals**: 核心指标监控

View File

@@ -1,13 +1,13 @@
## WWJCloud AI 文档总览
# AI 智能体开发指南
### 目标
## 目标
- **统一规范**: 用 NestJS 的方式实现,与 PHP 业务/数据100%对齐。
- **智能体协作**: 多个智能体基于同一规则、流程与工具偏好执行任务。
- **前后端协调**: 前后端智能体协调开发,确保一致性。
### 文档导航
## 文档导航
#### 后端开发
### 后端开发
- 工作流程: ./workflow.md
- 工具偏好: ./tooling.md
- 规则规范: ./rules.md
@@ -16,9 +16,10 @@
- 架构映射: ./mapping.md
- 开发规划: ./planner.md
#### 前端开发
### 前端开发
- 前端工作流: ./frontend-workflow.md
- 前后端协调: ./coordination.md
- 协调示例: ./coordination-example.md
### 适用范围
- 仓库: `wwjcloud/` 主后端及 `admin/apps/*` 示例
@@ -30,4 +31,6 @@
- 前台端路由: `/api/{module}/...`
- 配置表: `sys_config.value(JSON)`,禁止使用不存在字段(如 `config_value`, `app_type`
- 前端路由: 按模块组织,支持懒加载
- 组件命名: PascalCase语义化命名
- 组件命名: PascalCase语义化命名
- 页面结构: 主页面使用 Page 组件,子组件放在 modules 目录
- 测试规范: 单元测试放在 `__tests__/unit/` 目录,覆盖率 ≥ 80%

View File

@@ -10,6 +10,7 @@
- S7 规范审计体(Auditor): 按清单逐项核查,出具差异报告与修复项
- S8 上线管控体(Release): 构建、变更说明、灰度计划与回滚预案
- S9 性能优化体(PerfTuner): 建议缓存/异步化/批处理,识别大对象传输与 N+1开发后期与上线后持续执行
- S10 命名规范体(NamingGuard): 检查文件/类/方法/变量命名规范,确保符合大厂标准(开发中持续执行)
### 串联流程(带顺序)
1) S1 Analyzer
@@ -46,7 +47,12 @@
9) S9 PerfTuner并行/持续)
- 建议: 缓存、异步化、批量化、索引与查询优化;识别 N+1、大对象传输
10) S8 Release
10) S10 NamingGuard开发中持续执行
- 检查: 文件命名、类命名、方法命名、变量命名、数据库命名、API命名
- 输出: 命名规范检查报告、不符合规范的命名列表、修复建议
- 标准: 严格按照大厂命名规范执行,确保代码可读性和一致性
11) S8 Release
- 产出: 变更日志、部署步骤、数据迁移脚本、回滚预案
### 关键约束
@@ -60,6 +66,76 @@
- 事件: 统一用 DomainEventService事件名 `domain.aggregate.action`;默认 DB Outbox可切 Kafka
- Redis: 短缓存配置读取、上传限流/防刷(计数器)、幂等(SETNX+TTL)
### 命名规范(大厂标准)
#### 文件命名规范
- **目录名**: `camelCase` (如 `event`, `breaker`, `sdk`, `userManagement`)
- **文件名**: `camelCase.ts` (如 `baseSdk.ts`, `sdkManager.ts`, `userController.ts`)
- **模块文件**: `*.module.ts` (如 `user.module.ts`, `auth.module.ts`)
- **控制器文件**: `*.controller.ts` (如 `user.controller.ts`, `admin.controller.ts`)
- **服务文件**: `*.service.ts` (如 `user.service.ts`, `auth.service.ts`)
- **实体文件**: `*.entity.ts` (如 `user.entity.ts`, `role.entity.ts`)
- **DTO文件**: `*.dto.ts` (如 `createUser.dto.ts`, `updateUser.dto.ts`)
- **守卫文件**: `*.guard.ts` (如 `jwtAuth.guard.ts`, `roles.guard.ts`)
- **拦截器文件**: `*.interceptor.ts` (如 `logging.interceptor.ts`, `transform.interceptor.ts`)
- **管道文件**: `*.pipe.ts` (如 `validation.pipe.ts`, `parseInt.pipe.ts`)
- **装饰器文件**: `*.decorator.ts` (如 `roles.decorator.ts`, `user.decorator.ts`)
#### 类命名规范
- **类名**: `PascalCase` (如 `UserService`, `AuthController`, `BaseSdk`)
- **抽象类**: `Abstract` + `PascalCase` (如 `AbstractBaseService`, `AbstractRepository`)
- **接口名**: `IPascalCase` (如 `IUserService`, `IAuthRepository`, `ISdkManager`)
- **枚举名**: `PascalCase` (如 `UserStatus`, `AuthType`, `PermissionLevel`)
- **类型名**: `PascalCase` (如 `UserCreateDto`, `AuthResponse`, `SdkConfig`)
#### 方法命名规范
- **方法名**: `camelCase` (如 `getUserById`, `createUser`, `validateToken`)
- **私有方法**: `private` + `camelCase` (如 `private validateInput`, `private processData`)
- **异步方法**: `async` + `camelCase` (如 `async getUserById`, `async createUser`)
- **布尔方法**: `is`/`has`/`can` + `PascalCase` (如 `isValid`, `hasPermission`, `canAccess`)
#### 变量命名规范
- **变量名**: `camelCase` (如 `userName`, `authToken`, `configData`)
- **常量名**: `UPPER_SNAKE_CASE` (如 `MAX_RETRY_COUNT`, `DEFAULT_TIMEOUT`, `API_BASE_URL`)
- **私有变量**: `private` + `camelCase` (如 `private logger`, `private configService`)
- **只读变量**: `readonly` + `camelCase` (如 `readonly appName`, `readonly version`)
#### 数据库命名规范
- **表名**: `snake_case` (如 `sys_user`, `user_role`, `auth_token`)
- **字段名**: `snake_case` (如 `user_name`, `created_at`, `is_deleted`)
- **索引名**: `idx_表名_字段名` (如 `idx_user_email`, `idx_user_status`)
- **外键名**: `fk_表名_引用表名` (如 `fk_user_role_user_id`)
#### API命名规范
- **路由前缀**:
- 管理端: `/adminapi` (如 `/adminapi/user`, `/adminapi/auth`)
- 前台: `/api` (如 `/api/user`, `/api/auth`)
- **端点名**: `kebab-case` (如 `/user-management`, `/auth-token`)
- **HTTP方法**: 标准方法 (GET, POST, PUT, DELETE, PATCH)
#### 配置命名规范
- **环境变量**: `UPPER_SNAKE_CASE` (如 `DB_HOST`, `REDIS_PASSWORD`, `JWT_SECRET`)
- **配置键**: `camelCase` (如 `database.host`, `redis.password`, `jwt.secret`)
- **配置组**: `camelCase` (如 `database`, `redis`, `jwt`, `app`)
#### 特殊命名约定
- **PHP业务命名优先**: 在符合NestJS/TS规范前提下优先使用PHP业务术语
- **NestJS特有类型**: 严格按框架规范命名
- **避免缩写**: 使用完整单词,提高可读性
- **语义化命名**: 名称应清晰表达用途和含义
#### 命名检查清单
- [ ] 文件名使用 `camelCase.ts`
- [ ] 类名使用 `PascalCase`
- [ ] 接口名使用 `IPascalCase`
- [ ] 方法名使用 `camelCase`
- [ ] 变量名使用 `camelCase`
- [ ] 常量名使用 `UPPER_SNAKE_CASE`
- [ ] 表名使用 `snake_case`
- [ ] 环境变量使用 `UPPER_SNAKE_CASE`
- [ ] 避免使用缩写
- [ ] 名称语义清晰
### 命名与对齐
- PHP 业务命名优先(不违反 Nest/TS 规范前提下包括服务方法、DTO 字段、配置键
- Nest 特有类型按规范命名:`*.module.ts``*.controller.ts``*.app.service.ts``*.core.service.ts`
@@ -72,6 +148,7 @@
### 执行与验收CI/PR 建议)
- PR 必须通过: build、单测/集成/e2e
- 审计体根据 `checklists.md` 自动评论差异(字段/命名/路由/守卫/事务/队列/事件)
- 命名规范体(NamingGuard)检查所有文件命名、类命名、方法命名、变量命名
- 安全基线: 管理端控制器统一 `JwtAuthGuard + RolesGuard`/adminapi 与 /api 路由前缀
### 目录职能速查(防误用)