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/**"]
}