main
root 2 years ago
parent fc93e59929
commit 8f1f3fa1f1

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link href="/src/styles/loading.less" rel="stylesheet" />
<title>Vite App</title>
</head>
<body>
<div id="app">
<div class="loading-container">
<div class="dot-wrapper">
<span class="dot">
<i></i>
<i></i>
<i></i>
<i></i>
</span>
</div>
<h1>加载中</h1>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"allowSyntheticDefaultImports": true,
"moduleResolution": "Node",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules"]
}

3578
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,43 @@
{
"name": "msb-shop-admin",
"author": {
"name": "向文可",
"email": "1041367524@qq.com"
},
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite",
"build:test": "vite build --mode test",
"build:preview": "vite build --mode preview",
"build:prod": "vite build --mode prod",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons": "^0.0.11",
"axios": "^0.26.1",
"dayjs": "^1.11.0",
"element-plus": "^2.1.2",
"lodash": "^4.17.21",
"qs": "^6.10.3",
"sortablejs": "^1.14.0",
"vue": "^3.2.25",
"vue-router": "^4.0.14",
"vuex": "^4.0.2"
},
"devDependencies": {
"@originjs/vite-plugin-global-style": "^1.0.2",
"@types/node": "^17.0.21",
"@vitejs/plugin-legacy": "^1.7.1",
"@vitejs/plugin-vue": "^2.2.0",
"@vitejs/plugin-vue-jsx": "^1.3.8",
"consola": "^2.15.3",
"less": "^4.1.2",
"unplugin-auto-import": "^0.6.4",
"unplugin-vue-components": "^0.18.0",
"vite": "^2.8.0",
"vite-plugin-remove-console": "^0.0.6",
"vite-plugin-style-import": "^2.0.0",
"vite-plugin-svg-icons": "^2.0.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -0,0 +1,31 @@
<template>
<el-config-provider
:locale="config.locale"
:size="config.size"
:z-index="config.zIndex"
:button="config.button"
:message="config.message"
>
<router-view />
</el-config-provider>
</template>
<script setup>
import zh from 'element-plus/lib/locale/lang/zh-cn';
const route = useRoute();
const config = reactive({
locale: zh,
size: 'default',
zIndex: 300,
button: {
autoInsertSpace: true,
},
message: {
max: 5,
},
});
</script>
<style lang="less">
@import url('@/styles/base.less');
</style>

@ -0,0 +1,40 @@
import request from '@/utils/request';
// 获取验证码
export function sendSmsCode(params) {
return request({
url: '/uaa/sms/sendSms',
method: 'get',
params,
});
}
// 登录
export async function login(data) {
return request({
url: '/uaa/sso/appManageLogin',
method: 'post',
data,
});
}
// 获取用户信息
export function getUserInfo() {
return request({
url: '/uc/user/v1/info/token',
method: 'get',
});
}
// 获取权限列表
export function getPermission(params) {
return request({
url: '/u-admin/uc/ucPermission/listUserMenu',
method: 'get',
params,
});
}
// 退出登录
export function logout() {
return request({
url: '/uaa/sso/v1/logout',
method: 'get',
});
}

@ -0,0 +1,40 @@
const mock = (data) =>
new Promise((resolve) => {
setTimeout(() => {
resolve(data);
}, Math.random() * 1500 + 500);
});
export const findUserList = (data) => {
return mock({
content: [
{
id: 1,
username: 'user001',
nickname: '张三',
sex: 1,
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fedpic%2Ffd%2Ff1%2Fda%2Ffdf1dacb8ff0b8f13ed29bcbee42f328.jpeg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1650201540&t=ba213738d8f11e79302fab71602856f2',
loginTime: Date.now(),
enabled: true,
},
{
id: 2,
username: 'user003',
nickname: '李四',
sex: 0,
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fedpic%2Ffd%2Ff1%2Fda%2Ffdf1dacb8ff0b8f13ed29bcbee42f328.jpeg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1650201540&t=ba213738d8f11e79302fab71602856f2',
loginTime: Date.now(),
enabled: true,
},
{
id: 3,
username: 'user003',
nickname: '王五',
sex: 1,
avatar: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fedpic%2Ffd%2Ff1%2Fda%2Ffdf1dacb8ff0b8f13ed29bcbee42f328.jpeg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1650201540&t=ba213738d8f11e79302fab71602856f2',
loginTime: Date.now(),
enabled: false,
},
],
totalElements: 3,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

@ -0,0 +1,293 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElTable } from 'element-plus/es/components/table/index';
import 'element-plus/es/components/table/style/css';
import Sortable from 'sortablejs';
const props = defineProps({
/**
* 拖拽表格唯一标识用于区分多个拖拽区域互相拖拽时数据来源
*/
code: {
type: String,
default: '',
},
/**
* el-table原生属性表格数据代理element原生属性用于修改数据为排序后的数据
*/
data: {
type: Array,
required: true,
},
/**
* el-table原生属性唯一标识属性字段名默认id
*/
rowKey: {
type: String,
default: 'id',
},
/**
* 排序属性字段名默认sort
*/
sortKey: {
type: String,
default: 'sort',
},
/**
* 控制是否修改表格数据排序默认修改如果不修改则只改变表格渲染顺序但是真实数据顺序不变
*/
modifyData: {
type: Boolean,
default: true,
},
/**
* sortablejs原生属性分组详细用法见官方文档
* http://www.sortablejs.com/options.html#:~:text=group%EF%BC%9Astring%20or%20object
*/
group: {
type: [String, Object],
default: '',
},
/**
* sortablejs原生属性是否列表单元
*/
sort: {
type: Boolean,
default: true,
},
/**
* sortablejs原生属性是否此sortable对象是否可用
*/
disabled: {
type: Boolean,
default: false,
},
/**
* sortablejs原生属性动画持续时间
*/
animation: {
type: Number,
default: 150,
},
/**
* sortablejs原生属性可拖拽元素
*/
draggable: {
type: String,
default: 'el-table__row',
},
/**
* sortablejs原生属性元素可拖拽部分选择器默认为空表示元素任意部分都可拖拽
*/
handle: {
type: String,
default: '',
},
/**
* sortablejs原生属性幽灵元素
*/
ghostClass: {
type: String,
default: 'ghost-row',
},
/**
* sortablejs原生属性选中的元素
*/
chosenClass: {
type: String,
default: 'chosen-row',
},
/**
* sortablejs原生属性拖拽的元素
*/
dragClass: {
type: String,
default: 'drag-row',
},
/**
* sortablejs原生属性不允许拖拽元素选择器
*/
filter: {
type: String,
default: '.ignore-drag-sort',
},
});
const attrs = useAttrs();
const slots = useSlots();
const emits = defineEmits(['row-sort', 'row-add', 'row-remove', 'row-clone', 'expand-change']);
//
const uid = 'sortableTable' + getCurrentInstance().uid;
const { proxy } = getCurrentInstance();
// ref
const sortting = ref(false);
const refsTable = ref(null);
const expandRowKeys = ref([]);
const handleExpandChange = (row, expandRows) => {
expandRowKeys.value = expandRows.map((item) => item[props.rowKey]);
emits('expand-change', row, expandRows);
};
// sortablejs
let sortable = ref(null);
// sortablejs
const handleInit = () => {
//
const el = document.querySelector(`[sort-id='${uid}'] :not(.el-table) tbody`);
if (el) {
sortable.value = new Sortable(el, {
group: props.group,
sort: props.sort,
disabled: props.disabled,
animation: props.animation,
draggable: '.' + props.draggable,
handle: props.handle,
ghostClass: props.ghostClass,
chosenClass: props.chosenClass,
dragClass: props.dragClass,
filter: props.filter,
onUpdate(e) {
if (typeof e.newIndex === 'number') {
if (props.modifyData) {
//
const row = unref(props.data).splice(e.oldIndex, 1)[0];
unref(props.data).splice(e.newIndex, 0, row);
//
unref(props.data).forEach((item, index) => {
item[props.sortKey] = index + 1;
});
proxy.$nextTick(() => {
//
proxy.$nextTick(() => {
proxy.$forceUpdate();
//
emits('row-sort', e.newIndex, e.oldIndex, e);
});
});
} else {
//
emits('row-sort', e.newIndex, e.oldIndex, e);
}
}
},
setData(dataTransfer) {
dataTransfer.setData('code', props.code);
},
onAdd(e) {
// sort-id
let code = e.originalEvent.dataTransfer.getData('code');
//
emits(
'row-add',
e.newIndex,
e.oldIndex,
code,
(row) => {
if (props.modifyData && row) {
sortting.value = true;
unref(props.data).splice(e.newIndex, 0, row);
// v-if
proxy.$nextTick(() => {
sortting.value = false;
proxy.$nextTick(() => {
handleInit();
});
});
}
},
e
);
},
onRemove(e) {
if (typeof e.newIndex === 'number') {
if (props.modifyData && e.pullMode !== 'clone') {
unref(props.data).splice(e.oldIndex, 1);
proxy.$forceUpdate();
}
//
emits('row-remove', e.newIndex, e.oldIndex, e);
}
},
onClone(e) {
if (typeof e.newIndex === 'number') {
//
emits('row-clone', e.newIndex, e.oldIndex, e);
}
},
});
} else {
console.error('可拖拽表格ID不存在');
}
};
// sortablejs
onMounted(handleInit);
//
const handleProxy = (fnName, args) => {
return unref(refsTable)[fnName]?.apply(unref(refsTable), args);
};
const clearSelection = function () {
return handleProxy('clearSelection', arguments);
};
const toggleRowSelection = function () {
return handleProxy('toggleRowSelection', arguments);
};
const toggleAllSelection = function () {
return handleProxy('toggleAllSelection', arguments);
};
const toggleRowExpansion = function () {
return handleProxy('toggleRowExpansion', arguments);
};
const setCurrentRow = function () {
return handleProxy('setCurrentRow', arguments);
};
const clearSort = function () {
return handleProxy('clearSort', arguments);
};
const clearFilter = function () {
return handleProxy('clearFilter', arguments);
};
const doLayout = function () {
return handleProxy('doLayout', arguments);
};
const sort = function () {
return handleProxy('sort', arguments);
};
//
defineExpose({
refsTable,
clearSelection,
toggleRowSelection,
toggleAllSelection,
toggleRowExpansion,
setCurrentRow,
clearSort,
clearFilter,
doLayout,
sort,
});
const render = () => (
<ElTable
ref={refsTable}
{...props}
{...attrs}
class={{ 'sortable-table': true, [props.group]: true }}
expand-row-keys={unref(expandRowKeys)}
row-key={props.rowKey}
sort-id={unref(uid)}
onExxpandChange={() => handleExpandChange}
v-slots={unref(sortting) ? {} : slots}
/>
);
</script>
<style lang="less" scoped>
:deep(.ghost-row) {
background: #ddd;
}
:deep(.chosen-row) {
background: #eee;
}
:deep(.drag-row) {
background: #ccc;
}
</style>

@ -0,0 +1,611 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import SortableTable from './SortableTable.vue';
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index';
import 'element-plus/es/components/table/style/css';
const props = defineProps({
code: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
sortable: {
type: Boolean,
default: false,
},
config: {
type: Object,
required: true,
},
operation: {
type: Array,
default() {
return ['create', 'remove', 'search'];
},
},
total: {
type: Number,
default: 0,
},
});
const emits = defineEmits([
'create',
'save',
'remove',
'search',
'select',
'selectAll',
'selectionChange',
'currentChange',
'reset',
'template',
'import',
'export',
]);
const attrs = useAttrs();
const slots = useSlots();
const { proxy } = getCurrentInstance();
const store = useStore();
//
const getObjectProps = (obj) => {
return _.clone(Object.fromEntries(Object.entries(obj).filter((entry) => typeof entry[1] !== 'object')));
};
//
const settings = computed(() => store.state.local.listSettings);
const handleInit = (clear = false) => {
//
if (props.code && clear) {
store.commit('local/setListSettings', {
...unref(settings),
[props.code]: null,
});
console.info('[list] reset:' + props.code);
}
let config = null;
if (
!props.code ||
!unref(settings) ||
!unref(settings)[props.code] ||
!(unref(settings)[props.code] instanceof Array)
) {
//
config = props.config.columns
.map((item, index) => {
return {
_sort: index + 1,
_index: index,
_visible: true,
width: '',
fixed: false,
...getObjectProps(item),
};
})
.sort((prev, next) => prev._sort - next._sort);
if (props.code) {
//
store.commit('local/setListSettings', {
...unref(settings),
[props.code]: config,
});
}
} else {
config = unref(settings)[props.code];
}
//
let needReset = false;
config = props.config.columns.map((item, index) => {
//
let old = config.find((temp) => temp._index === index);
if (
old &&
Object.entries(getObjectProps(item)).every((entry) => {
return old[entry[0]] === entry[1];
})
) {
item = {
...old,
...item,
};
} else {
needReset = true;
}
return item;
});
return needReset ? handleInit(true) : config;
};
const setting = ref(handleInit());
console.info('[list] setting:' + props.code, unref(setting));
//
let rollback = ref([]);
const settingVisible = ref(false);
const handleSetting = () => {
rollback.value = _.cloneDeep(unref(setting));
settingVisible.value = true;
};
const handleCancel = () => {
settingVisible.value = false;
};
const handleSave = () => {
rollback.value = null;
store.commit('local/setListSettings', {
...unref(settings),
[props.code]: unref(setting).map((item) => getObjectProps(item)),
});
settingVisible.value = false;
};
const handleCloseSetting = () => {
if (unref(rollback)) {
setting.value = unref(rollback);
}
};
//
const refsUpload = ref(null);
const importVisible = ref(false);
const handleShowImport = () => {
importVisible.value = true;
};
const handleCancelImport = () => {
importVisible.value = false;
};
const handleSaveImport = (file) => {
const files = unref(refsUpload).refsUpload.uploadFiles;
if (!files.length) {
proxy.$baseMessage('请上传要导入的文件', 'error');
} else {
if (file === false) {
unref(refsUpload).refsUpload.submit();
} else {
handleImport(file);
importVisible.value = false;
}
}
};
//
const refTable = ref(null);
const checked = ref(null);
const selection = ref([]);
const handleSelect = (arr, row) => {
setTimeout(() => {
let res = [],
selected = arr.indexOf(row) !== -1;
(function deep(item) {
res.push(item);
(item.children || []).forEach(deep);
})(row);
res.forEach((item) => {
toggleRowExpansion(item, selected);
toggleRowSelection(item, selected);
});
}, 0);
emits('select', arr, row);
};
//
const pages = computed(() => store.state.local.listPage);
const search = ref(
props.code
? store.state.local.listPage[props.code]
: {
pageIndex: 1,
length: 10,
}
);
const resetPage = (
page = {
pageIndex: 1,
length: 10,
}
) => {
if (props.code) {
store.commit('local/setListPage', {
...unref(pages),
[props.code]: page,
});
}
search.value = page;
};
if (!unref(search)) {
resetPage();
}
watch(
() => props.total,
(value) => {
if (!value) {
resetPage();
}
}
);
//
const handleSearch = () => {
emits('search', unref(search));
};
const handleReset = () => {
emits('reset');
search.value = { pageIndex: 1, length: 10 };
handleSearch();
};
const handleTemplate = () => {
emits('template');
};
const handleImport = (files) => {
emits('import', files);
};
const handleExport = () => {
emits('export');
};
const handleCreate = () => {
emits('create', unref(checked));
};
const handleUpdate = () => {
emits('save', unref(selection));
};
const handleRemove = () => {
emits('remove', unref(selection));
};
const listeners = {
onSelect: handleSelect,
onSelectAll: (arr) => {
setTimeout(() => {
attrs.data.forEach((item) => handleSelect(arr, item));
}, 0);
emits('selectAll', arr);
},
onSelectionChange: (arr) => {
selection.value = arr;
emits('selectionChange', arr);
},
onCurrentChange: (row) => {
checked.value = row;
emits('currentChange', row);
},
};
//
watch(
search,
(value) => {
if (value && props.code) {
resetPage(value);
}
handleSearch();
},
{ deep: true, immediate: props.config.autoSearch !== false }
);
//
const handleProxy = (fnName, args) => {
return unref(refTable)[fnName]?.apply(unref(refTable), args);
};
const clearSelection = function () {
return handleProxy('clearSelection', arguments);
};
const toggleRowSelection = function () {
return handleProxy('toggleRowSelection', arguments);
};
const toggleAllSelection = function () {
return handleProxy('toggleAllSelection', arguments);
};
const toggleRowExpansion = function () {
return handleProxy('toggleRowExpansion', arguments);
};
const setCurrentRow = function () {
return handleProxy('setCurrentRow', arguments);
};
const clearSort = function () {
return handleProxy('clearSort', arguments);
};
const clearFilter = function () {
return handleProxy('clearFilter', arguments);
};
const doLayout = function () {
return handleProxy('doLayout', arguments);
};
const sort = function () {
return handleProxy('sort', arguments);
};
//
defineExpose({
search: handleSearch,
selection,
refTable,
clearSelection,
toggleRowSelection,
toggleAllSelection,
toggleRowExpansion,
setCurrentRow,
clearSort,
clearFilter,
doLayout,
sort,
});
const Component = props.sortable ? SortableTable : ElTable;
const render = () => (
<div class="common-list">
{slots.search ? <div class="search-box">{slots.search()}</div> : ''}
<div class="operation-box">
{props.operation.includes('create') ? (
<ElButton type="primary" onClick={() => handleCreate()}>
<ElIcon name="Plus" />
<span>新增{props.title}</span>
</ElButton>
) : (
''
)}
{props.operation.includes('save') ? (
<ElButton type="primary" onClick={() => handleUpdate()}>
<ElIcon name="Tickets" />
<span>批量保存</span>
</ElButton>
) : (
''
)}
{props.operation.includes('remove') ? (
<ElButton type="danger" onClick={() => handleRemove()}>
<ElIcon name="Delete" />
<span>批量移除</span>
</ElButton>
) : (
''
)}
{props.operation.includes('search') ? (
<ElButton type="success" onClick={() => handleSearch()}>
<ElIcon name="Search" />
<span>查询</span>
</ElButton>
) : (
''
)}
{props.operation.includes('search') ? (
<ElButton onClick={() => handleReset()}>
<ElIcon name="MagicStick" />
<span>重置</span>
</ElButton>
) : (
''
)}
{props.operation.includes('import') ? (
<ElButton type="warning" onClick={() => handleShowImport()}>
<ElIcon name="Document" />
<span>模板导入</span>
</ElButton>
) : (
''
)}
{props.operation.includes('export') ? (
<ElButton type="warning" onClick={() => handleExport()}>
<ElIcon name="Download" />
<span>导出</span>
</ElButton>
) : (
''
)}
{slots.operation?.()}
{props.operation.includes('import') ? (
<ElDialog
title={'模板导入 - ' + props.title || props.code}
v-model={importVisible.value}
width="765px"
v-slots={{
footer: () => {
return (
<div>
<ElButton type="danger" onClick={() => handleCancelImport()}>
取消
</ElButton>
<ElButton type="primary" onClick={() => handleSaveImport(false)}>
导入
</ElButton>
</div>
);
},
}}
>
<ElButton icon="Document" type="warning" onClick={() => handleTemplate()}>
下载模板
</ElButton>
<br />
<br />
<ElUpload
ref={refsUpload}
auto-upload={false}
before-upload={(e) => {
handleSaveImport(e);
return false;
}}
/>
</ElDialog>
) : (
''
)}
{props.code ? (
<ElButton class="setting-btn" icon="setting" type="text" onClick={() => handleSetting()}>
<ElIcon name="Setting" />
<span>设置</span>
</ElButton>
) : (
''
)}
<ElDialog
title={'表格设置 - ' + props.title || props.code}
v-model={settingVisible.value}
width="800px"
onClose={() => handleCloseSetting()}
v-slots={{
footer: () => {
return (
<div>
<ElButton type="danger" onClick={() => handleCancel()}>
取消
</ElButton>
<ElButton type="primary" onClick={() => handleSave()}>
保存
</ElButton>
</div>
);
},
}}
>
<p>修改后如果显示异常刷新页面即可恢复正常</p>
<br />
<ElTable border stripe highlightCurrentRow data={setting.value} height="50vh">
<ElTableColumn headerAlign="center" align="center" label="排序" prop="_sort" width="72px" />
<ElTableColumn
headerAlign="center"
align="center"
label="列名"
prop="label"
minWidth="200px"
v-slots={{
default: ({ row }) =>
row.type === 'index' ? (
'序号'
) : row.type === 'selection' ? (
'选择框'
) : (
<ElInput
v-model={row.label}
placeholder={props.config.columns[row._index].label}
/>
),
}}
/>
<ElTableColumn
headerAlign="center"
align="center"
label="宽度"
prop="width"
width="140px"
v-slots={{
default: ({ row }) => <ElInput v-model={row.width} style="width:104px;" />,
}}
/>
<ElTableColumn
headerAlign="center"
align="center"
label="固定"
prop="fixed"
width="240px"
v-slots={{
default: ({ row }) => (
<ElRadioGroup
v-model={row.fixed}
button
opts={[
{ label: '左侧', value: 'left' },
{ label: '取消', value: false },
{ label: '右侧', value: 'right' },
]}
></ElRadioGroup>
),
}}
/>
<ElTableColumn
headerAlign="center"
align="center"
label="显示"
prop="_visible"
width="72px"
v-slots={{
default: ({ row }) => <ElSwitch v-model={row._visible} />,
}}
/>
</ElTable>
</ElDialog>
</div>
<div class="content-box">
<Component
ref={refTable}
border
stripe
highlightCurrentRow={false}
rowKey="id"
height="100%"
{...listeners}
{...attrs}
{...props.config.table}
v-slots={{
empty: () => (
<div class="empty-content">
<ElEmpty description="没有查询到符合条件的数据" />
</div>
),
...props.config.table?.slots,
}}
>
{unref(setting).map((col) =>
col._visible ? (
<ElTableColumn
showOverflowTooltip={!col.slots?.default}
headerAlign="center"
align="center"
selectable={(row) => !row.baseData}
v-slots={{ ...col.slots }}
{...col}
></ElTableColumn>
) : (
''
)
)}
</Component>
</div>
<div class="pagination-box">
{props.config.page === false ? (
''
) : (
<ElPagination
background
hide-on-single-page={false}
page-sizes={props.config.page?.sizes || [10, 15, 20, 30, 50, 100]}
layout={props.config.page?.layout || 'total, sizes, prev, pager, next, jumper'}
total={props.total}
v-model:current-page={unref(search).pageIndex}
v-model:page-size={unref(search).length}
/>
)}
</div>
</div>
);
</script>
<style lang="less" scoped>
.common-list {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.search-box) {
border-radius: 5px;
}
:deep(.operation-box) {
border-radius: 5px;
padding-bottom: 10px;
display: flex;
align-items: center;
.setting-btn {
margin-left: auto;
}
}
:deep(.content-box) {
flex: 1;
overflow: hidden;
margin-bottom: 10px;
display: flex;
flex-direction: column;
.el-table {
max-height: 100%;
flex: 1;
}
}
:deep(.pagination-box) {
display: flex;
justify-content: center;
.el-pagination {
margin: 0;
}
}
}
</style>

@ -0,0 +1,19 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElButton } from 'element-plus/es/components/button/index';
import 'element-plus/es/components/button/style/css';
const props = defineProps({});
const attrs = useAttrs();
const slots = useSlots();
const render = () => <ElButton {...props} {...attrs} v-slots={slots} />;
</script>
<style lang="less" scoped>
.el-button {
:deep(.el-icon) {
position: relative;
top: -2px;
}
}
</style>

@ -0,0 +1,18 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElCascader } from 'element-plus/es/components/cascader/index';
import 'element-plus/es/components/cascader/style/css';
const props = defineProps({
clearable: {
type: Boolean,
default: true,
},
});
const attrs = useAttrs();
const slots = useSlots();
const render = () => <ElCascader {...props} {...attrs} v-slots={slots}></ElCascader>;
</script>
<style lang="less" scoped></style>

@ -0,0 +1,61 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElCheckboxGroup } from 'element-plus/es/components/checkbox/index';
import 'element-plus/es/components/checkbox/style/css';
const props = defineProps({
opts: {
type: Array,
required: true,
},
config: {
type: Object,
default() {
return {
label: 'label',
value: 'value',
disabled: 'disabled',
};
},
},
button: {
type: Boolean,
default: false,
},
});
const slots = useSlots();
const attrs = useAttrs();
let config = {
label: 'label',
value: 'value',
disabled: 'disabled',
...props.config,
};
function handleItemDisabled(item, index) {
let res = false;
if (config.disabled instanceof Function) {
res = config.disabled(item, index);
} else {
res = !!item[config.disabled];
}
return res;
}
const render = () => (
<ElCheckboxGroup
{...props}
{...attrs}
v-slots={{
default: () =>
props.opts.map((item, index) => (
<ElCheckbox label={item[config.value]} disabled={handleItemDisabled(item, index)}>
{item[config.label]}
</ElCheckbox>
)),
...slots,
}}
></ElCheckboxGroup>
);
</script>
<style lang="less" scoped></style>

@ -0,0 +1,105 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElScrollbar } from 'element-plus/es/components/scrollbar/index';
import { ElDialog } from 'element-plus/es/components/dialog/index';
import 'element-plus/es/components/dialog/style/css';
import 'element-plus/es/components/scrollbar/style/css';
const props = defineProps({
destroyOnClose: {
type: Boolean,
default: true,
},
draggable: {
type: Boolean,
default: true,
},
closeOnClickModal: {
type: Boolean,
default: false,
},
closeOnPressEscape: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:fullscreen']);
const slots = useSlots();
const attrs = useAttrs();
const fullscreen = ref(attrs.fullscreen || false);
const handleFullScreen = () => {
fullscreen.value = !unref(fullscreen);
emit('update:fullscreen', unref(fullscreen));
};
const dialogSlots = {
...slots,
default() {
return (
<ElScrollbar max-height={unref(fullscreen) ? '100%' : '60vh'}>
<div>{slots.default?.()}</div>
</ElScrollbar>
);
},
title() {
return (
<div class="el-dialog__header-wrapper">
{slots.title?.() || <span class="el-dialog__title">{attrs.title || '弹窗'}</span>}
<ElButton
aria-label="fullscreen"
class="el-dialog__headerbtn fullscreen-btn"
type="text"
onClick={() => handleFullScreen()}
>
<ElIcon class="el-dialog__fullscreen" name={unref(fullscreen) ? 'Minus' : 'FullScreen'} />
</ElButton>
</div>
);
},
};
const render = () => <ElDialog {...props} {...attrs} fullscreen={unref(fullscreen)} v-slots={dialogSlots} />;
</script>
<style lang="less">
.el-dialog {
display: flex;
flex-direction: column;
overflow: hidden;
&.is-fullscreen {
display: flex;
flex-direction: column;
.el-dialog__body {
flex: 1;
}
}
.el-dialog__header {
padding: @layout-space-large;
.el-dialog__header-wrapper {
display: flex;
justify-content: space-between;
.fullscreen-btn {
position: static;
height: auto;
color: var(--el-button-text-color, var(--el-text-color-regular));
:hover {
color: var(--el-color-primary);
}
}
}
}
.el-dialog__body {
padding: @layout-space-large;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.el-scrollbar {
width: 100%;
height: 100%;
}
}
.el-dialog__headerbtn {
top: calc(@layout-space-large + 5px);
height: auto;
}
}
</style>

@ -0,0 +1,62 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus/es/components/dropdown/index';
import 'element-plus/es/components/dropdown/style/css';
const props = defineProps({
type: {
type: String,
default: 'text',
},
icon: {
type: String,
default: 'ArrowDown',
},
splitButton: {
type: Boolean,
default: false,
},
opts: {
type: Array,
required: true,
},
});
const attrs = useAttrs();
const slots = useSlots();
const handleCommand = (index) => {
props.opts[index].onClick?.();
};
const dropdownSlots = {
dropdown: () => (
<ElDropdownMenu>
{(props.opts || []).map((item, index) => (
<ElDropdownItem command={index} disabled={item.disabled} divided={item.divided}>
{item.label}
</ElDropdownItem>
))}
</ElDropdownMenu>
),
default: () => (
<ElButton type="text">
{slots.default?.() || '更多'}
<ElIcon name={props.icon} size="16" />
</ElButton>
),
};
const render = () => (
<ElDropdown {...props} {...attrs} onCommand={(command) => handleCommand(command)} v-slots={dropdownSlots} />
);
</script>
<style lang="less" scoped>
.el-dropdown {
:deep(.x-icon) {
top: 0;
margin-left: @layout-space-small;
}
}
.el-button + .el-dropdown {
margin-left: 10px;
}
</style>

@ -0,0 +1,55 @@
<template>
<template v-if="name">
<svg v-if="svg" class="x-icon" aria-hidden="true">
<use :href="symbolId" :fill="color" />
</svg>
<i v-else-if="isRemix" class="x-icon" :class="'x-icon-' + name" v-bind="{ ...$props, ...$attrs }"></i>
<ElIcon v-else class="x-icon">
<component :is="icons[name]" />
</ElIcon>
</template>
</template>
<script setup>
import * as icons from '@element-plus/icons';
import { ElIcon } from 'element-plus/es/components/icon/index';
const props = defineProps({
//
name: {
type: String,
default: '',
},
//
size: {
type: [String, Number],
default: 14,
},
// SVG
svg: {
type: Boolean,
default: false,
},
//
color: {
type: String,
default: 'inherit',
},
});
// SVG
const symbolId = computed(() => `#icon-${props.name}`);
//
const size = computed(() => (Number.isNaN(new Number(props.size).valueOf()) ? props.size : props.size + 'px'));
// remixelement-plus
const isRemix = computed(() => !Object.keys(icons).includes(props.name));
</script>
<style lang="less" scoped>
.x-icon {
font-size: v-bind(size);
color: v-bind(color);
line-height: 1;
}
svg.x-icon {
width: v-bind(size);
height: v-bind(size);
}
</style>

@ -0,0 +1,87 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElImage } from 'element-plus/es/components/image/index';
import 'element-plus/es/components/image/style/css';
const props = defineProps({
src: {
type: String,
default: 'none',
},
alt: {
type: String,
required: true,
},
width: {
type: String,
default: 'auto',
},
height: {
type: String,
default: 'auto',
},
fit: {
type: String,
default: 'contain',
},
zIndex: {
type: Number,
default: 9999,
},
hideOnClickModal: {
type: Boolean,
default: true,
},
lazy: {
type: Boolean,
default: true,
},
previewSrcList: {
type: Array,
default: () => [],
},
previewTeleported: {
type: Boolean,
default: true,
},
});
const slots = useSlots();
const attrs = useAttrs();
const imageSlots = {
placeholder: () => (
<div class="image-slot">
<ElIcon name="Picture" size="20" />
</div>
),
error: () => (
<div class="image-slot">
<ElIcon name="file-damage-fill" size="20" />
</div>
),
...slots,
};
if (props.previewSrcList?.length === 0) {
props.previewSrcList.push(props.src);
}
const width = computed(() => props.width);
const height = computed(() => props.height);
const render = () => <ElImage {...props} {...attrs} v-slots={imageSlots} />;
</script>
<style lang="less" scoped>
.el-image {
width: v-bind(width);
height: v-bind(height);
:deep(.image-slot) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: var(--el-text-color-secondary);
font-size: 30px;
}
}
</style>

@ -0,0 +1,53 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElInput } from 'element-plus/es/components/input/index';
import 'element-plus/es/components/input/style/css';
const props = defineProps({
clearable: {
type: Boolean,
default: true,
},
showWordLimit: {
type: Boolean,
default: true,
},
placeholder: {
type: String,
default: '请输入',
},
type: {
type: String,
default: 'text',
},
showPassword: {
type: Boolean,
default(props) {
return props.type === 'password';
},
},
showWordLimit: {
type: Boolean,
default: true,
},
rows: {
type: Number,
default: 3,
},
autosize: {
type: [Boolean, Object],
default() {
return { minRows: 5, maxRows: 10 };
},
},
resize: {
type: String,
default: 'none',
},
});
const attrs = useAttrs();
const slots = useSlots();
const render = () => <ElInput {...props} {...attrs} v-slots={slots}></ElInput>;
</script>
<style lang="less" scoped></style>

@ -0,0 +1,62 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElRadioGroup, ElRadioButton, ElRadio } from 'element-plus/es/components/radio/index';
import 'element-plus/es/components/radio/style/css';
import 'element-plus/es/components/radio-button/style/css';
const props = defineProps({
opts: {
type: Array,
required: true,
},
config: {
type: Object,
default() {
return {
label: 'label',
value: 'value',
disabled: 'disabled',
};
},
},
button: {
type: Boolean,
default: false,
},
});
const attrs = useAttrs();
const slots = useSlots();
let config = {
label: 'label',
value: 'value',
disabled: 'disabled',
...props.config,
};
function handleItemDisabled(item, index) {
let res = false;
if (config.disabled instanceof Function) {
res = config.disabled(item, index);
} else {
res = !!item[config.disabled];
}
return res;
}
const Component = props.button ? ElRadioButton : ElRadio;
const render = () => (
<ElRadioGroup
{...props}
{...attrs}
v-slots={{
default: () =>
props.opts.map((item, index) => (
<Component label={item[config.value]} disabled={handleItemDisabled(item, index)}>
{item[config.label]}
</Component>
)),
...slots,
}}
/>
);
</script>
<style lang="less" scoped></style>

@ -0,0 +1,74 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import { ElSelect, ElOption } from 'element-plus/es/components/select/index';
import 'element-plus/es/components/select/style/css';
const props = defineProps({
opts: {
type: Array,
required: true,
},
clearable: {
type: Boolean,
default: true,
},
filterable: {
type: Boolean,
default: true,
},
config: {
type: Object,
default() {
return {
label: 'label',
value: 'value',
disabled: 'disabled',
};
},
},
});
const slots = useSlots();
const attrs = useAttrs();
let config = {
label: 'label',
value: 'value',
disabled: 'disabled',
...props.config,
};
function handleItemDisabled(item, index) {
let res = false;
if (config.disabled instanceof Function) {
res = config.disabled(item, index);
} else {
res = !!item[config.disabled];
}
return res;
}
const opts = computed(() =>
props.opts.map((item) => {
return typeof item !== 'object' ? { label: item, value: item } : item;
})
);
const render = () => (
<ElSelect
{...props}
{...attrs}
v-slots={{
default: () =>
unref(opts).map((item, index) => (
<ElOption
label={item[config.label]}
value={item[config.value]}
disabled={handleItemDisabled(item, index)}
>
{slots.option?.(item, index) || item[config.label]}
</ElOption>
)),
...slots,
}}
></ElSelect>
);
</script>
<style lang="less" scoped></style>

@ -0,0 +1,202 @@
<template>
<component :is="render" />
</template>
<script setup lang="jsx">
import config from '@/configs';
import { ElMessage } from '@/plugins/element-plus';
import { ElUpload } from 'element-plus/es/components/upload/index';
import 'element-plus/es/components/upload/style/css';
import { ElImage } from 'element-plus/es/components/image/index';
import 'element-plus/es/components/image/style/css';
const store = useStore();
const props = defineProps({
action: {
type: String,
default: config.baseURL + '/edu-oss/oss/fileUpload',
},
data: {
type: Object,
default() {
return { service: 'msb-edu-course' };
},
},
headers: {
type: Object,
default() {
return {};
},
},
drag: {
type: Boolean,
default: true,
},
multiple: {
type: Boolean,
},
limit: {
type: Number,
default: 1,
},
size: {
type: Number,
default: 1024 * 1024 * 20,
},
accept: {
type: String,
default: '*.*',
},
});
const attrs = useAttrs();
const emits = defineEmits(['update:modelValue']);
props.headers['Authorization'] = 'Bearer ' + store.state.local.token;
let imgList = ref([]);
const refsUpload = ref(null);
watch(
() => imgList,
() => {
if (props.limit === 1) {
emits('update:modelValue', unref(imgList)[0]?.response.data);
} else {
emits('update:modelValue', unref(imgList));
}
},
{ deep: true }
);
const handleSuccess = (res, file, list) => {
console.info('[upload] success', list);
imgList.value = list;
};
const handleRemove = (file, list) => {
console.info('[upload] remove', list);
imgList.value = list;
};
const handleExceed = (list) => {
console.info('[upload] exceed', list);
ElMessage.error('超出最大上传数量');
};
const handleBeforeUpload = (file) => {
console.info('[upload] upload', file);
if (file.size >= props.size) {
ElMessage.error('超出文件大小限制');
return false;
}
};
const fmtSize = computed(() => {
const units = ['byte', 'KB', 'MB', 'GB', 'TB'];
let res = props.size,
unit = 0;
while (res >= 800) {
res /= 1024;
unit++;
}
return res + units[unit];
});
watch(
() => attrs.modelValue,
(value) => {
if (props.limit === 1 && value) {
imgList.value = [
{
name: value,
response: {
data: value,
},
},
];
} else {
imgList.value = value || [];
}
},
{ immediate: true, deep: true }
);
const handleDeleteImage = (index) => {
if (unref(refsUpload)) {
unref(refsUpload).handleRemove(imgList[index]);
} else {
unref(imgList).splice(index, 1);
}
};
const render = () => (
<div class="upload-box">
<div class="upload-image">
{unref(imgList).map((item, index) => (
<div class="img-li">
<ElImage src={item?.response?.data} alt={item.name} />
<div class="img-li-cover" onClick={() => handleDeleteImage(index)}>
<ElIcon class="upload-del-icon" name="delete-bin-fill" size="20" />
</div>
</div>
))}
{props.limit != unref(imgList).length ? (
<ElUpload
ref={refsUpload}
{...props}
{...attrs}
before-upload={handleBeforeUpload}
on-exceed={handleExceed}
on-remove={handleRemove}
on-success={handleSuccess}
show-file-list={false}
>
<ElIcon class="el-icon--upload" name="add-fill" size="20" />
</ElUpload>
) : (
''
)}
</div>
<div class="el-upload__tip">支持小于 {unref(fmtSize)} 文件</div>
</div>
);
</script>
<style lang="less" scoped>
.upload-box {
:deep(.upload-image) {
display: flex;
flex-wrap: wrap;
.img-li {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 150px;
height: 150px;
margin-right: 20px;
overflow: hidden;
.img-li-cover {
display: none;
}
&:hover {
.img-li-cover {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
.upload-del-icon {
color: #fff;
}
}
}
}
}
}
:deep(.el-upload) {
width: 150px;
height: 150px;
.el-upload-dragger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.el-icon--upload {
margin: 0;
}
}
}
</style>

@ -0,0 +1,14 @@
export default {
/**
* 接口请求地址前缀
*/
baseURL: import.meta.env.VITE_BASE_URL,
/**
* 接口请求超时时间
*/
requestTimeout: import.meta.env.VITE_REQUEST_TIMEOUT,
/**
* 是否使用本地路由
*/
useLocalRouter: false,
};

@ -0,0 +1,2 @@
import 'virtual:svg-icons-register';
import '@/icons/remixicon.less';

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="144px" height="144px" viewBox="0 0 144 144" enable-background="new 0 0 144 144" xml:space="preserve"> <image id="image0" width="144" height="144" x="0" y="0"
href="data:image/png;base64,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" />
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M1 3h4l7 12 7-12h4L12 22 1 3zm8.667 0L12 7l2.333-4h4.035L12 14 5.632 3h4.035z"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

@ -0,0 +1,4 @@
const fs = require('fs');
let data = fs.readFileSync('./remixicon.less').toString();
let res = data.match(/(?<=\.x-icon-).*?(?=:before)/g);
fs.writeFileSync('index.json', JSON.stringify(res));

@ -0,0 +1,78 @@
<template>
<el-scrollbar class="layout-aside">
<el-menu collapse :default-active="activeAside">
<el-menu-item v-for="(item, index) in asideList" :key="index" :index="item.name" @click="handleClick(item)">
<el-icon :name="item.meta.icon" size="30" />
<p>{{ item.meta.title }}</p>
</el-menu-item>
</el-menu>
</el-scrollbar>
</template>
<script setup>
const router = useRouter();
const store = useStore();
const asideList = computed(() => store.getters['layout/asideList']);
const activeAside = computed(() => store.state.layout.activeAside);
//
watch(
() => activeAside,
(value) => {
store.commit(
'layout/setMenuList',
unref(asideList).find((item) => item.name === unref(value))?.children || []
);
},
{ immediate: true, deep: true }
);
const handleClick = (item) => {
store.commit('layout/setActiveAside', item.name);
};
</script>
<style lang="less" scoped>
.layout-aside {
flex: 1;
width: @layout-aside-width;
height: 100%;
background-color: @color-black;
:deep(.el-scrollbar__view) {
height: 100%;
.el-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: @layout-aside-fc;
--el-menu-item-height: @layout-aside-item-size;
--el-menu-hover-bg-color: @layout-aside-fc2;
border-right: none;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.el-menu-item {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 !important;
border-radius: @layout-border-radius;
width: @layout-aside-item-size;
.x-icon {
margin-bottom: @layout-space;
}
P {
line-height: 1;
}
&.is-active {
background-color: var(--el-menu-active-color);
color: @layout-aside-fc;
}
+ .el-menu-item {
margin-top: @layout-space;
}
}
}
}
}
</style>

@ -0,0 +1,30 @@
<template>
<el-breadcrumb separator="/">
<template v-for="(item, index) in breakcrumbList" :key="index">
<el-breadcrumb-item v-if="index === breakcrumbList.length - 1">{{ item.meta.title }}</el-breadcrumb-item>
<el-breadcrumb-item v-else :to="{ name: item.name }">
{{ item.meta.title }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</template>
<script setup>
const store = useStore();
const breakcrumbList = computed(() => store.state.layout.breakcrumbList);
</script>
<style lang="less" scoped>
.el-breadcrumb {
font-size: 16px;
margin-left: @layout-space-super;
:deep(.el-breadcrumb__item) {
.el-breadcrumb__inner {
color: @layout-header-fc;
a {
color: @layout-header-fc;
}
}
}
}
</style>

@ -0,0 +1,20 @@
<template>
<div class="layout-footer">© 2020 马士兵北京教育科技有限公司</div>
</template>
<script setup>
import Breakcrumb from './breakcrumb.vue';
</script>
<style lang="less" scoped>
.layout-footer {
width: 100%;
padding: @layout-space-small 0;
display: flex;
align-items: center;
justify-content: center;
background-color: @layout-footer-bgc;
box-shadow: @layout-shadow;
color: @layout-footer-fc;
}
</style>

@ -0,0 +1,46 @@
<template>
<div class="layout-header">
<div class="header-left">
<el-icon :name="collapseMenu ? 'menu-unfold-fill' : 'menu-fold-fill'" size="16" @click="handleCollapse" />
<LayoutBreakcrumb />
</div>
<div class="header-right">
<LayoutOperation />
<LayoutProfile />
</div>
</div>
</template>
<script setup>
import LayoutBreakcrumb from './breakcrumb.vue';
import LayoutProfile from './profile.vue';
import LayoutOperation from './operation.vue';
const store = useStore();
const collapseMenu = computed(() => store.getters['layout/collapseMenu']);
const handleCollapse = () => {
store.commit('layout/setCollapseMenu', !unref(collapseMenu));
};
</script>
<style lang="less" scoped>
.layout-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background-color: @layout-header-bgc;
color: @layout-header-fc;
box-shadow: @layout-shadow;
.header-left,
.header-right {
height: @layout-header-height;
padding: 0 @layout-space-large;
display: flex;
align-items: center;
}
.x-icon {
border-radius: @layout-border-radius;
cursor: pointer;
}
}
</style>

@ -0,0 +1,24 @@
<template>
<div class="layout-logo">
<el-icon name="msb" svg :size="size" />
</div>
</template>
<script setup>
import variables from '@/styles/globalVariables.module.less';
const size = variables.layoutLogoSize;
</script>
<style lang="less" scoped>
.layout-logo {
width: @layout-aside-width;
height: @layout-header-height;
display: flex;
align-items: center;
justify-content: center;
background-color: @color-black;
.x-icon {
border-radius: @layout-border-radius;
}
}
</style>

@ -0,0 +1,30 @@
<template>
<el-scrollbar class="layout-main" always>
<RouterView />
</el-scrollbar>
</template>
<script setup>
import RouterView from './view.vue';
</script>
<style lang="less" scoped>
.layout-main {
margin: @layout-space-large;
background-color: @color-white;
border-radius: @layout-border-radius;
box-shadow: @layout-shadow;
> :deep(.el-scrollbar__wrap) {
> .el-scrollbar__view {
width: 100%;
height: 100%;
> * {
// display: inline-block; // BFC
width: 100%;
height: 100%;
padding: @layout-space-large;
}
}
}
}
</style>

@ -0,0 +1,64 @@
<template>
<el-menu-item v-if="!menuItem.children?.length" :index="props.menuItem.name" :route="{ name: props.menuItem.name }">
<el-icon :name="props.menuItem.meta.icon" size="20" />
<p>{{ props.menuItem.meta.title }}</p>
</el-menu-item>
<el-sub-menu v-else :index="props.menuItem.name">
<template #title>
<el-icon :name="props.menuItem.meta.icon" size="20" />
<p>{{ props.menuItem.meta.title }}</p>
</template>
<MenuItem v-for="(item, index) in menuItem.children" :key="index" :menu-item="item" />
</el-sub-menu>
</template>
<script setup>
const props = defineProps({
menuItem: {
type: Object,
required: true,
},
});
</script>
<style lang="less" scoped>
.x-icon {
width: @layout-icon;
color: @layout-menu-ic;
}
.el-menu-item {
width: 100%;
min-width: unset;
border-radius: @layout-border-radius;
&:hover {
color: @layout-menu-hover-fc;
}
&.is-active {
background-color: @layout-menu-active-bgc;
color: @layout-menu-active-fc;
}
+ .el-menu-item {
margin-top: @layout-space-small;
}
}
.el-sub-menu {
border-radius: @layout-border-radius;
&.is-opened {
background-color: @color-ghost-black;
> :deep(.el-sub-menu__title) {
background-color: @layout-menu-hover-bgc;
}
}
:deep(.el-sub-menu__title) {
border-radius: @layout-border-radius;
margin-bottom: @layout-space-small;
}
+ .el-menu-item {
margin-top: @layout-space-small;
}
:deep(.el-menu) {
padding: 0 !important;
border-radius: @layout-border-radius;
}
}
</style>

@ -0,0 +1,89 @@
<template>
<el-scrollbar class="layout-menu" :class="{ collapse: collapseMenu }">
<div class="title">马士兵严选后台管理平台</div>
<el-divider>{{ activeAsideName }}</el-divider>
<el-menu unique-opened :default-active="activeMenu" @select="handleSelect">
<MenuItem v-for="(item, index) in menuList" :key="index" :menu-item="item" />
</el-menu>
</el-scrollbar>
</template>
<script setup>
import MenuItem from './menu-item.vue';
const router = useRouter();
const store = useStore();
const activeAsideName = computed(() => store.getters['layout/activeAsideName']);
const activeAside = computed(() => store.state.layout.activeAside);
const menuList = computed(() => store.state.layout.menuList);
const activeMenu = computed(() => store.state.layout.activeMenu);
const collapseMenu = computed(() => store.getters['layout/collapseMenu']);
const handleSelect = (index) => {
// TODO
router.push({ name: index });
};
//
watch(
() => unref(menuList),
(value) => {
if (value.length === 1) {
handleSelect(value[0].name);
} else if (value.length === 0) {
handleSelect(unref(activeAside));
}
},
{
immediate: true,
deep: true,
}
);
</script>
<style lang="less" scoped>
.layout-menu {
flex: 1;
width: @layout-menu-width;
height: 100%;
background-color: @layout-menu-bgc;
box-shadow: @layout-shadow;
padding: 0 @layout-space;
transition: width 0.3s;
&.collapse {
width: 0;
padding: 0;
overflow: hidden;
:deep(*) {
opacity: 0;
transition: opacity 0.3s;
}
}
:deep(*) {
white-space: nowrap;
}
.title {
width: 100%;
height: @layout-header-height;
line-height: @layout-header-height;
font-size: @layout-h2;
color: @layout-menu-active-fc;
text-align: center;
.text-overflow();
}
.el-divider {
margin: 0 0 @layout-space-super 0;
border-color: @color-ghost-white;
:deep(.el-divider__text) {
color: @layout-menu-active-fc;
background-color: @layout-menu-bgc;
}
}
:deep(.el-scrollbar__view) {
height: 100%;
.el-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: @layout-menu-fc;
--el-menu-hover-bg-color: @layout-menu-hover-bgc;
border-right: none;
}
}
}
</style>

@ -0,0 +1,38 @@
<template>
<div class="layout-operation">
<el-button type="text" @click="handleFullscreen">
<el-icon :name="isFullscreen ? 'fullscreen-exit-fill' : 'fullscreen-fill'" />
</el-button>
</div>
</template>
<script setup>
const isFullscreen = ref(!!document.fullscreenElement);
const handleFullscreen = () => {
if (isFullscreen.value) {
document.exitFullscreen();
isFullscreen.value = false;
} else {
isFullscreen.value = true;
let fn =
document.body.requestFullscreen ||
document.body.webkitRequestFullscreen ||
document.body.mozRequestFullscreen ||
document.body.msRequestFullscreen;
document.fullscreenEnabled && fn?.call(document.body);
}
};
</script>
<style lang="less" scoped>
.layout-operation {
height: @layout-header-height;
padding: 0 @layout-space-large;
display: flex;
align-items: center;
justify-content: center;
.el-avatar {
margin-right: @layout-space;
}
}
</style>

@ -0,0 +1,39 @@
<template>
<div class="layout-profile">
<el-avatar :src="userInfo?.avatar" />
<el-dropdown :opts="opts">
<span>{{ userInfo?.nickname || userInfo?.username }}</span>
</el-dropdown>
</div>
</template>
<script setup>
const store = useStore();
const userInfo = computed(() => store.state.auth.userInfo);
const opts = reactive([
{
label: '个人中心',
onClick() {
alert('个人中心');
},
},
{
label: '退出登录',
onClick() {
store.dispatch('auth/logout');
},
},
]);
</script>
<style lang="less" scoped>
.layout-profile {
height: @layout-header-height;
display: flex;
align-items: center;
justify-content: center;
.el-avatar {
margin-right: @layout-space;
}
}
</style>

@ -0,0 +1,157 @@
<template>
<div class="layout-tabs">
<el-tabs :modelValue="activeTab" type="card" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane v-for="(item, index) in tabList" :key="index" :name="item.name">
<template #label>
<el-icon class="tab-icon" :name="item.meta.icon" />
<span>{{ item.meta.title }}</span>
<el-icon
v-if="tabList.length > 1"
class="tab-close"
name="close-fill"
@click.stop="handleCloseTab(index)"
/>
</template>
</el-tab-pane>
</el-tabs>
<div class="operation">
<el-dropdown trigger="hover" :opts="opts" icon="apps-fill">
<span></span>
</el-dropdown>
</div>
</div>
</template>
<script setup>
const router = useRouter();
const store = useStore();
const activeTab = computed(() => store.state.layout.activeTab);
const tabList = computed(() => store.state.layout.tabList);
//
watch(
() => unref(tabList),
(value) => {
if (!unref(activeTab) || value.findIndex((item) => item.name === unref(activeTab)) === -1) {
router.push({ name: value[0].name });
}
},
{ immediate: true, deep: true }
);
const handleCloseTab = (index) => {
store.commit('layout/closeTab', {
index,
});
};
const handleClick = (tab) => {
router.push(unref(tabList)[tab.index].fullPath);
};
const opts = reactive([
{
label: '关闭全部',
onClick() {
store.commit('layout/closeTab', {});
},
},
{
label: '关闭其他',
onClick() {
store.commit('layout/closeTab', {
index: unref(tabList).findIndex((item) => item.name === unref(activeTab)),
reverse: true,
});
},
},
]);
</script>
<style lang="less" scoped>
.layout-tabs {
width: 100%;
height: @layout-tabs-height;
padding: 0 @layout-space-large;
display: flex;
align-items: flex-end;
justify-content: space-between;
overflow: hidden;
box-shadow: fade(@layout-header-fc, 15%) 0 1px 5px;
background-color: @layout-header-bgc;
color: @layout-header-fc;
.el-tabs {
height: 80%;
width: 100%;
flex-shrink: 1;
overflow: hidden;
:deep(.el-tabs__header) {
margin: 0;
height: 100%;
.el-tabs__nav-wrap {
height: 100%;
margin-bottom: 0;
> * {
height: 100%;
display: flex;
align-items: center;
}
.el-tabs__nav-scroll {
.el-tabs__nav {
height: 100%;
border: none;
padding: 0 @layout-space-large;
display: flex;
.el-tabs__item {
height: 100%;
min-width: 100px;
margin-right: -8px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
mask-image: url('~/layouts/tabs-bgp.png');
-webkit-mask-image: url('~/layouts/tabs-bgp.png');
mask-size: 100% 100%;
-webkit-mask-size: 100% 100%;
&:hover {
background-color: @color-white-dark;
.tab-close {
width: 20px;
}
}
&.is-active {
background-color: @color-primary-white;
.tab-close {
width: 20px;
}
}
.tab-icon {
margin-right: @layout-space-small;
}
.tab-close {
margin-left: @layout-space-small;
border-radius: 50px;
display: inline-block;
width: 0;
overflow: hidden;
transition: width 0.3s;
&:hover {
color: var(--el-color-danger);
}
}
}
}
}
}
}
}
.operation {
height: 80%;
margin-left: @layout-space;
display: flex;
align-items: center;
&:hover {
transform: rotate(90deg);
}
}
}
</style>

@ -0,0 +1,13 @@
<template>
<router-view v-slot="{ Component }">
<transition mode="out-in" name="fade-transform">
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup></script>
<style lang="less" scoped></style>

@ -0,0 +1,49 @@
<template>
<div class="layout-container layout-default">
<div class="layout-left">
<LayoutLogo />
<LayoutAside />
</div>
<div class="layout-center">
<LayoutMenu />
</div>
<div class="layout-right">
<LayoutHeader />
<LayoutTabs />
<LayoutMain />
<LayoutFooter />
</div>
</div>
</template>
<script setup>
import LayoutMain from './components/main.vue';
import LayoutLogo from './components/logo.vue';
import LayoutAside from './components/aside.vue';
import LayoutMenu from './components/menu.vue';
import LayoutHeader from './components/header.vue';
import LayoutTabs from './components/tabs.vue';
import LayoutFooter from './components/footer.vue';
</script>
<style lang="less" scoped>
.layout-container {
width: 100%;
height: 100%;
background-color: @color-white-dark;
}
.layout-default {
display: flex;
.layout-left {
display: flex;
flex-direction: column;
box-shadow: @layout-shadow;
}
.layout-right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
</style>

@ -0,0 +1,17 @@
import { createApp } from 'vue';
import App from './App.vue';
import '@/icons';
const app = createApp(App);
import store from '@/store';
app.use(store);
import router from '@/router';
app.use(router);
import usePlugins from '@/plugins';
usePlugins(app);
app.mount('#app');

@ -0,0 +1,16 @@
import { ElNotification } from 'element-plus/es/components/notification/index';
import 'element-plus/es/components/notification/style/css';
import { ElMessage } from 'element-plus/es/components/message/index';
import 'element-plus/es/components/message/style/css';
import { ElMessageBox } from 'element-plus/es/components/message-box/index';
import 'element-plus/es/components/message-box/style/css';
import { ElLoading } from 'element-plus/es/components/loading/index';
import 'element-plus/es/components/loading/style/css';
// 只需要引用带有全局方法的组件,其他组件插件会自动按需引入
export default (app) => {
app.use(ElNotification);
app.use(ElMessage);
app.use(ElMessageBox);
app.use(ElLoading);
};
export { ElNotification, ElMessage, ElMessageBox, ElLoading };

@ -0,0 +1,6 @@
export default (app) => {
Object.entries(import.meta.globEager('./*.js')).forEach((entry) => {
entry[1].default?.(app);
console.info('[plugins] loaded ' + entry[0].split('/').pop().split('.').reverse().slice(1).reverse().join());
});
};

@ -0,0 +1,22 @@
export default [
{
path: '/demo',
name: 'Demo',
component: () => import('@/layouts/default.vue'),
meta: {
title: '组件示例',
icon: 'home-fill',
},
children: [
{
path: 'sortable',
name: 'SortableTableDemo',
component: () => import('@/views/demo/sortableTableDemo.vue'),
meta: {
title: '拖拽排序',
icon: 'home-fill',
},
},
],
},
];

@ -0,0 +1,137 @@
import { createRouter, createWebHistory } from 'vue-router';
// 全局路由
export const globalRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/global/login.vue'),
meta: {
global: true,
},
},
{
path: '/404',
name: '404',
component: () => import('@/views/global/404.vue'),
meta: {
global: true,
},
},
];
// 示例模块
import demoModule from './demo';
export const demeRoutes = import.meta.env.DEV ? demoModule : [];
// 动态模块
const dynamicRoutes = [];
const modules = import.meta.globEager('./modules/*.js');
Object.values(modules).forEach((mod) => {
dynamicRoutes.push(...mod.default);
});
// 本地路由
export const routes = [
...globalRoutes,
{
path: '/',
name: 'App',
redirect: { name: 'Home' },
component: () => import('@/layouts/default.vue'),
meta: {
layout: true,
},
children: [
{
path: '/home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
icon: 'home-fill',
},
},
],
},
...dynamicRoutes,
{
path: '/:pathMatch(.*)*',
redirect: '/404',
name: 'NotFound',
meta: {
global: true,
},
},
];
import config from '@/configs';
const router = createRouter({
history: createWebHistory(),
routes: config.useLocalRouter ? routes : [],
});
import store from '@/store';
router.onError((error, to) => {
console.info('[router] error', error, to);
});
router.beforeEach(async (to, from, next) => {
if (!from.matched.length) {
store.loadCache();
}
if (store.state.local.token) {
if (!store.state.auth.permission.length) {
if (config.useLocalRouter) {
store.commit('auth/setPermission', routes);
} else {
await store.dispatch('auth/getUserInfo');
await store.dispatch('auth/getPermission');
}
next({ ...to, replace: true });
} else {
console.info(`[router] from ${from.name} to ${to.name}`);
const deep = (route) => {
let child = (route.children || []).find((item) => !item.meta?.hidden);
return child?.children?.length ? deep(child?.children) : child?.name;
};
let childName = deep([...to.matched].pop());
if (childName) {
next({ name: childName });
} else {
next();
store.commit('layout/setActiveAside', to.matched.find((item) => !item.meta?.layout).name);
store.commit('layout/setActiveMenu', to.name);
store.commit('layout/setActiveTab', to.name);
store.commit('layout/setBreakcrumbList', to.matched);
store.commit('layout/addTab', to);
}
}
} else if (to.meta.global) {
next();
} else {
next({ name: 'Login' });
}
});
export default router;
export const reset = (routers) => {
router.getRoutes().forEach((item) => {
router.removeRoute(item.name);
});
routers = [
...globalRoutes,
...routers,
{
path: '/:pathMatch(.*)*',
redirect: '/404',
name: 'NotFound',
meta: {
global: true,
},
},
];
routers.forEach(router.addRoute);
console.info('[router] reset', router.getRoutes());
};

@ -0,0 +1,286 @@
export default [
{
path: '/education',
name: 'Education',
component: () => import('@/layouts/default.vue'),
meta: {
title: '教务教学',
icon: 'book-open-fill',
},
children: [
{
path: 'teacher',
name: 'TeacherManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '老师管理',
icon: 'user-shared-fill',
},
},
{
path: 'student',
name: 'StudentManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '学生管理',
icon: 'user-received-fill',
},
children: [
{
path: 'list',
name: 'StudentManagementList',
component: () => import('@/views/home/index.vue'),
meta: {
title: '学生列表',
icon: 'user-received-line',
},
},
{
path: 'grade',
name: 'GradeManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '年级管理',
icon: 'account-pin-box-fill',
},
},
{
path: 'class',
name: 'ClassManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '班级管理',
icon: 'account-pin-circle-fill',
},
},
],
},
{
path: 'admission',
name: 'AdmissionManagement',
component: () => import('@/views/home/index.vue'),
component: () => import('@/views/home/index.vue'),
meta: {
title: '入学',
icon: 'award-fill',
},
children: [
{
path: 'evaluation',
name: 'AdmissionEvaluation',
component: () => import('@/views/home/index.vue'),
meta: {
title: '入学评测',
icon: 'file-paper-2-fill',
},
},
{
path: 'way',
name: 'StudyWay',
component: () => import('@/views/home/index.vue'),
meta: {
title: '学习路线',
icon: 'send-plane-fill',
},
},
],
},
{
path: 'answer',
name: 'Answer',
component: () => import('@/views/home/index.vue'),
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑',
icon: 'question-answer-fill',
},
children: [
{
path: 'management',
name: 'AnswerManagement',
component: () => import('@/views/home/index.vue'),
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑管理',
icon: 'question-answer-fill',
},
children: [
{
path: 'list',
name: 'AnswerManagementList',
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑管理列表',
icon: 'question-answer-line',
},
},
{
path: 'invalid',
name: 'InvalidQuestionManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '无效追问管理',
icon: 'questionnaire-fill',
},
},
{
path: 'lock',
name: 'UserLockManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '用户锁定管理',
icon: 'lock-fill',
},
},
],
},
{
path: 'assign/:questionId',
name: 'AssignAnswerTeacher',
component: () => import('@/views/home/index.vue'),
meta: {
title: '指派老师',
icon: 'account-box-fill',
hidden: true,
activeMenu: '/education/answer/management',
},
},
{
path: 'teacher',
name: 'AnswerTeacher',
component: () => import('@/views/home/index.vue'),
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑老师管理',
icon: 'book-line',
},
children: [
{
path: 'summary',
name: 'AnswerTeacherSummary',
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑老师统计',
icon: 'book-2-line',
},
},
{
path: 'management',
name: 'AnswerTeacherManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '答疑老师管理',
icon: 'book-3-line',
},
},
{
path: 'bind/:teacher?',
name: 'AnswerTeacherUpdateBind',
component: () => import('@/views/home/index.vue'),
meta: {
title: '编辑课程绑定',
icon: 'book-fill',
hidden: true,
activeMenu: '/education/answer/teacher/management',
},
},
{
path: 'course',
name: 'AnswerTeacherBindCourse',
component: () => import('@/views/home/index.vue'),
meta: {
title: '绑定课程管理',
icon: 'book-fill',
},
},
],
},
],
},
{
path: 'question',
name: 'QuestionManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '题库管理',
icon: 'brush-fill',
},
},
{
path: 'note',
name: 'NoteManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '笔记管理',
icon: 'sticky-note-2-fill',
},
},
{
path: 'research',
name: 'CourseResearch',
component: () => import('@/views/home/index.vue'),
component: () => import('@/views/home/index.vue'),
meta: {
title: '课研更新',
icon: 'contacts-book-upload-fill',
},
children: [
{
path: 'plan',
name: 'CourseResearchPlan',
component: () => import('@/views/home/index.vue'),
meta: {
title: '课研计划',
icon: 'contacts-book-upload-line',
},
},
{
path: 'log',
name: 'CourseResearchLog',
component: () => import('@/views/home/index.vue'),
meta: {
title: '课研更新日志',
icon: 'file-copy-2-line',
},
},
],
},
{
path: 'live',
name: 'LiveManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '直播管理',
icon: 'live-fill',
},
},
{
path: 'material',
name: 'MaterialManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '老师资料管理',
icon: 'database-2-fill',
},
},
{
path: 'shift',
name: 'ShiftTable',
component: () => import('@/views/home/index.vue'),
meta: {
title: '排班表',
icon: 'calendar-check-line',
},
},
{
path: 'course',
name: 'CourseTable',
component: () => import('@/views/home/index.vue'),
meta: {
title: '课程表管理',
icon: 'calendar-todo-fill',
},
},
],
},
];

@ -0,0 +1,49 @@
export default [
{
path: '/system',
name: 'SystemManagement',
component: () => import('@/layouts/default.vue'),
meta: {
title: '系统管理',
icon: 'settings-3-fill',
},
children: [
{
path: 'log',
name: 'LogManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '日志管理',
icon: 'file-chart-fill',
},
},
{
path: 'app',
name: 'AppManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: 'APP包管理',
icon: 'app-store-fill',
},
},
{
path: 'notify',
name: 'NotifyManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '通知管理',
icon: 'alarm-warning-fill',
},
},
{
path: 'file',
name: 'FileManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '文件中台',
icon: 'file-copy-fill',
},
},
],
},
];

@ -0,0 +1,49 @@
export default [
{
path: '/user',
name: 'UserManagement',
component: () => import('@/layouts/default.vue'),
meta: {
title: '用户管理',
icon: 'folder-user-fill',
},
children: [
{
path: 'consult',
name: 'ConsultManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '咨询管理',
icon: 'kakao-talk-fill',
},
},
{
path: 'course',
name: 'CourseConsultManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '咨询课程',
icon: 'chat-smile-2-fill',
},
},
{
path: 'account',
name: 'AccountManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '账号课程',
icon: 'account-box-fill',
},
},
{
path: 'feedback',
name: 'FeedbackManagement',
component: () => import('@/views/home/index.vue'),
meta: {
title: '意见反馈',
icon: 'feedback-fill',
},
},
],
},
];

@ -0,0 +1,53 @@
import { createStore } from 'vuex';
const modules = Object.fromEntries(
Object.entries(import.meta.globEager('./**/*.js')).map((entry) => {
let arr = entry[0].split('/').pop().split('.');
arr.pop();
let moduleName = _.camelCase(arr.join('-'));
return [
moduleName,
{
...entry[1].default,
namespaced: true,
},
];
})
);
const store = createStore({
strict: process.env.NODE_ENV !== 'production',
modules,
});
const storeCache = () => {
localStorage.setItem('storeCache', JSON.stringify(store.state.local));
// sessionStorage.setItem('storeCache', JSON.stringify(store.state));
console.info('[store] store cache');
};
const loadCache = () => {
let storeCache = sessionStorage.getItem('storeCache');
if (storeCache) {
let init = _.cloneDeep(store.state);
storeCache = Object.assign(init, JSON.parse(storeCache));
sessionStorage.removeItem('storeCache');
} else {
storeCache = { ...store.state };
}
let localCache = localStorage.getItem('storeCache');
if (localCache) {
storeCache.local = _.cloneDeep(store.state.local);
Object.assign(storeCache.local, JSON.parse(localCache));
}
store.replaceState(storeCache);
console.info('[store] load cache');
};
window._open = window.open;
window.open = function () {
storeCache();
window._open.apply(window, arguments);
};
window.addEventListener('beforeunload', storeCache);
store.storeCache = storeCache;
store.loadCache = loadCache;
export default store;

@ -0,0 +1,115 @@
import { getUserInfo, getPermission, sendSmsCode, login } from '@/api/auth';
import { ElMessage } from '@/plugins/element-plus';
import router, { reset as resetRoutes, demeRoutes } from '@/router';
import config from '@/configs';
const viewComponents = import.meta.glob('../../views/**/*.vue');
const state = () => ({
userInfo: null,
permission: [],
});
const getters = {};
const mutations = {
setUserInfo: (state, data) => (state.userInfo = data),
setPermission: (state, data) => {
const convert = (arr, parent = { path: '/', meta: {} }) => {
return arr.map((item) => {
let route = {
path: item.routeUrl,
name: item.roleCode,
component: item.pageUrl,
meta: {
title: item.title,
icon: item.icon,
hidden: item.type === 2,
activeMenu: item.activeMenu,
},
};
if (!route.path.startsWith('/')) {
if (!parent.path.endsWith('/')) {
parent.path += '/';
}
route.path = parent.path + route.path;
}
route.meta.menu = route.path.replaceAll(/\/:[^?]+\?/g, '');
let activeMenu = route.meta.activeMenu;
if (!activeMenu && route.meta.hidden) {
activeMenu = parent.meta.menu;
}
route.meta.activeMenu = activeMenu || route.meta.menu;
if (route.component) {
if (route.component === 'Layout') {
route.component = () => import('@/layouts/default.vue');
} else {
let path = `../../views/${route.component}`;
if (!path.endsWith('.vue')) {
path += '.vue';
}
let alias = path.replace(/\.vue$/, '/index.vue');
route.component = viewComponents[path] || viewComponents[alias];
if (!route.component) {
route.component = () => import('@/views/global/404.vue');
}
}
}
route.children = convert(item.children || [], route);
route.redirect = route.children.find((item) => !item.meta.hidden)?.menu;
return route;
});
};
data = config.useLocalRouter ? data : convert(data);
data.push(...demeRoutes);
state.permission = data;
resetRoutes(state.permission);
},
};
const actions = {
sms: async ({ commit }, data) => {
let res = await sendSmsCode(data);
if (res) {
ElMessage.success('验证码已发送');
commit('local/sendMessage', {}, { root: true });
} else {
ElMessage.error('发送验证码失败');
}
return res;
},
login: async ({ commit }, data) => {
let res = await login(data);
if (res) {
ElMessage.success('登陆成功');
commit('local/setToken', res.token, { root: true });
router.push('/');
} else {
ElMessage.error('登陆失败');
}
return res;
},
logout: ({ commit }) => {
commit('local/setToken', null, { root: true });
router.push({ name: 'Login' });
},
getPermission: async ({ commit }) => {
let res = await getPermission({ systemCode: 'msb_shop_admin' });
if (res) {
commit('setPermission', res);
} else {
ElMessage.error('加载权限信息失败');
}
return res;
},
getUserInfo: async ({ commit }) => {
let res = await getUserInfo();
if (res) {
commit('setUserInfo', res);
} else {
ElMessage.error('加载用户信息失败');
}
return res;
},
};
export default {
state,
getters,
mutations,
actions,
};

@ -0,0 +1,20 @@
const state = () => ({
count: 0,
});
const getters = {
doubleCount: (state) => state.count * 2,
};
const mutations = {
add: (state, num = 1) => (state.count += num),
};
const actions = {
clear: ({ state, commit }) => {
commit('add', state.count * -1);
},
};
export default {
state,
getters,
mutations,
actions,
};

@ -0,0 +1,58 @@
import router from '@/router';
const state = () => ({
activeAside: null,
collapseMenu: false,
activeMenu: null,
menuList: [],
tabList: [],
activeTab: null,
breakcrumbList: [],
});
const getters = {
asideList: (state, getters, rootState) => {
const deep = (arr) =>
arr.flatMap((item) => (item.meta?.layout ? deep(item.children || []) : item.meta?.global ? [] : item));
return deep(rootState.auth.permission);
},
collapseMenu: (state) => state.collapseMenu || state.menuList.length < 2,
activeAsideName: (state, getters) => getters.asideList.find((item) => item.name === state.activeAside)?.meta.title,
};
const mutations = {
setCollapseMenu: (state, data) => (state.collapseMenu = data),
setActiveAside: (state, data) => (state.activeAside = data),
setMenuList: (state, data) => (state.menuList = data),
setActiveMenu: (state, data) => (state.activeMenu = data),
addTab: (state, data) => {
let oldIndex = state.tabList.findIndex((item) => item.name === data.name);
let tab = null;
if (oldIndex !== -1) {
tab.fullPath = data.fullPath;
} else {
tab = _.cloneDeep(data);
state.tabList.push(tab);
}
},
closeTab: (state, { index, reverse }) => {
if (typeof index === 'undefined') {
state.tabList.splice(1);
} else {
if (reverse) {
let tab = state.tabList[index];
router.push({ name: tab.name });
state.tabList = [tab];
} else if (state.tabList.length > 1) {
state.tabList.splice(index, 1);
router.push({ name: state.tabList[Math.min(index, state.tabList.length - 1)].name });
}
}
},
setActiveTab: (state, data) => (state.activeTab = data),
setBreakcrumbList: (state, data) => (state.breakcrumbList = data),
};
const actions = {};
export default {
state,
getters,
mutations,
actions,
};

@ -0,0 +1,20 @@
const state = () => ({
lastSendMessageTime: 0,
token: null,
listSettings: {},
listPage: {},
});
const getters = {};
const mutations = {
sendMessage: (state) => (state.lastSendMessageTime = new Date().getTime()),
setToken: (state, data) => (state.token = data),
setListSettings: (state, data) => (state.listSettings = data),
setListPage: (state, data) => (state.listPage = data),
};
const actions = {};
export default {
state,
getters,
mutations,
actions,
};

@ -0,0 +1,39 @@
import * as api from '@/api/system/user.js';
import { ElMessage } from '@/plugins/element-plus';
const state = () => ({
loading: false,
list: [],
total: 0,
opts: {
sex: [
{ label: '男', value: 1 },
{ label: '女', value: 0 },
],
},
});
const getters = {};
const mutations = {
setLoading: (state, data) => (state.loading = data),
setList: (state, data) => (state.list = data),
setTotal: (state, data) => (state.total = data),
};
const actions = {
search: async ({ commit }, data) => {
commit('setLoading', true);
let res = await api.findUserList(data);
if (res) {
commit('setList', res.content);
commit('setTotal', res.totalElements);
} else {
ElMessage.error('查询用户失败');
commit('setList', []);
}
commit('setLoading', false);
},
};
export default {
state,
getters,
mutations,
actions,
};

@ -0,0 +1,20 @@
html,
body,
#app {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
}
ul,
ol {
list-style: none;
}
// *:not([class^='el-']):not([class^='el-'] *) {
*:not([class^='el-']) {
margin: 0;
padding: 0;
transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
box-sizing: border-box;
word-break: break-all;
}

@ -0,0 +1,58 @@
/* color */
@color-black: #282c34;
@color-white: #fff;
@color-white-dark: #f6f8f9;
@color-primary: #409eff;
@color-primary-white: #e8f4ff;
@color-ghost-white: fade(@color-white, 10%);
@color-ghost-black: fade(@color-black, 10%);
/* mixin */
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* layout */
@layout-icon: 30px;
@layout-h1: 30px;
@layout-h2: 20px;
@layout-h3: 16px;
@layout-h4: 14px;
@layout-border-radius: 5px;
@layout-space: 10px;
@layout-space-small: 5px;
@layout-space-large: 15px;
@layout-space-super: 20px;
@layout-shadow: fade(@color-black, 20%) 0px 0px 5px;
@layout-aside-bgc: @color-black;
@layout-aside-fc: @color-white;
@layout-aside-fc2: lighten(@color-black, 20%);
@layout-aside-width: 80px;
@layout-aside-item-size: 68px;
@layout-menu-width: 200px;
@layout-menu-bgc: lighten(@color-black, 20%);
@layout-menu-fc: darken(@color-white, 20%);
@layout-menu-ic: darken(@color-white, 20%);
@layout-menu-hover-bgc: lighten(@color-black, 25%);
@layout-menu-hover-fc: darken(@color-white, 10%);
@layout-menu-active-fc: @color-white;
@layout-menu-active-bgc: lighten(@color-black, 35%);
@layout-header-height: 60px;
@layout-header-bgc: @color-white;
@layout-header-fc: @color-black;
@layout-tabs-height: 50px;
@layout-footer-bgc: @color-white;
@layout-footer-fc: @color-black;
:export {
layoutAsideWidth: @layout-aside-width;
layoutLogoSize: calc(@layout-header-height * 0.72);
}

@ -0,0 +1,75 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 90vh;
min-height: 90vh;
h1 {
font-size: 28px;
font-weight: bolder;
}
.dot-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 98px;
.dot {
position: relative;
box-sizing: border-box;
display: inline-block;
width: 64px;
height: 64px;
font-size: 64px;
transform: rotate(45deg);
animation: rotate 1.2s infinite linear;
i {
position: absolute;
display: block;
width: 28px;
height: 28px;
background-color: #1890ff;
border-radius: 100%;
opacity: 0.3;
transform: scale(0.75);
transform-origin: 50% 50%;
animation: spin 1s infinite linear alternate;
&:nth-child(1) {
top: 0;
left: 0;
}
&:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
&:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
&:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
}
}
}
}
@keyframes rotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@keyframes spin {
to {
opacity: 1;
}
}

@ -0,0 +1,17 @@
const during = (func, name = 'func') => {
let time = Date.now(),
error = null;
try {
func();
} catch (e) {
error = e;
}
const msg = `[debug] exec ${name} during ${Date.now() - time}ms`;
if (error) {
msg += ' with error';
}
console.info(msg, error);
};
export default {
during,
};

@ -0,0 +1,74 @@
import config from '@/configs';
import store from '@/store';
import qs from 'qs';
import { ElMessage } from '@/plugins/element-plus';
const handleResponse = async ({ config, headers, data, status }) => {
if (
['application/octet-stream', 'application/zip'].indexOf(headers['content-type']) !== -1 ||
config['down'] === 'file'
) {
return data;
}
let code = data.code || status;
console.info('[api]', code, config.method, config.url, data.data);
if (code !== 200) {
ElMessage.error(data.msg || '服务器异常');
switch (code) {
case 500:
case 501:
break;
case 524:
case 525:
case 50008:
case 50012:
case 50014:
store.dispatch('auth/logout');
break;
case 404:
case 9999:
console.warn('接口9999', config);
break;
default:
}
}
return data?.data;
};
const instance = axios.create({
baseURL: config.baseURL,
timeout: config.requestTimeout,
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
});
instance.interceptors.request.use(
(config) => {
const token = store.state.local.token;
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
if (config.data && config.headers['Content-Type'] === 'application/x-www-form-urlencoded;charset=UTF-8') {
config.data = qs.stringify(config.data);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response) => handleResponse(response),
(error) => {
if (!error.response) {
ElMessage.error('服务器无响应');
return null;
} else {
return handleResponse(error.response);
}
}
);
export default instance;

@ -0,0 +1,218 @@
<template>
<div class="flaw-category-container">
<ul>
<li>设置group属性实现多个拖拽表格互相拖拽</li>
<li>group需要配合code属性判断数据是从哪里拖过来的</li>
<li>设置row-class-name给某些行加上filter属性指定的class ( 默认ignore-drag-sort ) 实现禁止拖拽</li>
<li>row-sort | row-remove | row-clone 事件入参: 新下标旧下标事件对象</li>
<li>row-add事件入参: 新下标旧下标来源表格code来源数据provider函数事件对象</li>
<li>单表格拖拽触发 row-add</li>
<li>多表格拖拽来源表格触发 row-remove 或者 row-clone ,目的表单触发 row-add</li>
</ul>
<p>{{ list.map((item) => item.id) }}</p>
<p>{{ list.find((item) => item.id === '1').childList.map((item) => item.id) }}</p>
<SortableTable
v-loading="listLoading"
border
code="parent"
:data="list"
group="test"
:row-class-name="handleRowClassName"
@row-add="handleRowAdd"
@row-clone="handleRowClone"
@row-remove="handleRowRemove"
@row-sort="handleRowSort"
>
<el-table-column label="展开" type="expand" width="60">
<template #default="{ row }">
<SortableTable
v-loading="listLoading"
border
code="child"
:data="row.childList || []"
:group="{ name: 'test', pull: 'clone' }"
:row-class-name="handleRowClassName"
@row-add="handleChildrenRowAdd"
@row-clone="handleChildrenRowClone"
@row-remove="handleChildrenRowRemove"
@row-sort="handleChildrenRowSort"
>
<el-table-column align="center" label="排序" prop="sort" width="60" />
<el-table-column align="center" label="ID" prop="id" show-overflow-tooltip />
<el-table-column align="center" label="字段1" prop="key1" show-overflow-tooltip />
<el-table-column align="center" label="字段2" prop="key2" show-overflow-tooltip />
<el-table-column align="center" label="字段3" prop="key3" show-overflow-tooltip />
<el-table-column align="center" label="字段4" prop="key4" show-overflow-tooltip />
<el-table-column align="center" label="字段5" prop="key5" show-overflow-tooltip />
<template #empty>
<el-empty class="vab-data-empty" description="暂无数据" />
</template>
</SortableTable>
</template>
</el-table-column>
<el-table-column align="center" label="排序" prop="sort" width="60" />
<el-table-column align="center" label="ID" prop="id" show-overflow-tooltip />
<el-table-column align="center" label="字段1" prop="key1" show-overflow-tooltip />
<el-table-column align="center" label="字段2" prop="key2" show-overflow-tooltip />
<el-table-column align="center" label="字段3" prop="key3" show-overflow-tooltip />
<el-table-column align="center" label="字段4" prop="key4" show-overflow-tooltip />
<el-table-column align="center" label="字段5" prop="key5" show-overflow-tooltip />
<template #empty>
<el-empty class="vab-data-empty" description="暂无数据" />
</template>
</SortableTable>
</div>
</template>
<script>
import { ElMessage } from '@/plugins/element-plus';
export default defineComponent({
name: 'SortableTableDemo',
setup() {
/* 通用 */
// proxyvue2this
const { proxy } = getCurrentInstance();
const commonState = reactive({
//
layout: 'total, sizes, prev, pager, next, jumper',
});
/* 查询 */
const queryState = reactive({
//
listLoading: false,
//
list: [
{
id: '1',
sort: 1,
key1: '1-1',
key2: '1-2',
key3: '1-3',
key4: '1-4',
key5: '1-5',
childList: [
{ id: '1-1', sort: 1, key1: '1-1', key2: '1-2', key3: '1-3', key4: '1-4', key5: '1-5' },
{ id: '1-2', sort: 2, key1: '2-1', key2: '2-2', key3: '2-3', key4: '2-4', key5: '2-5' },
{ id: '1-3', sort: 3, key1: '3-1', key2: '3-2', key3: '3-3', key4: '3-4', key5: '3-5' },
{ id: '1-4', sort: 4, key1: '4-1', key2: '4-2', key3: '4-3', key4: '4-4', key5: '4-5' },
{ id: '1-5', sort: 5, key1: '5-1', key2: '5-2', key3: '5-3', key4: '5-4', key5: '5-5' },
],
},
{
id: '2',
sort: 2,
key1: '2-1',
key2: '2-2',
key3: '2-3',
key4: '2-4',
key5: '2-5',
childList: [],
},
{
id: '3',
sort: 3,
key1: '3-1',
key2: '3-2',
key3: '3-3',
key4: '3-4',
key5: '3-5',
childList: [],
},
{
id: '4',
sort: 4,
key1: '4-1',
key2: '4-2',
key3: '4-3',
key4: '4-4',
key5: '4-5',
childList: [],
},
{
id: '5',
sort: 5,
key1: '5-1',
key2: '5-2',
key3: '5-3',
key4: '5-4',
key5: '5-5',
childList: [],
},
],
//
total: 0,
//
queryForm: {
pageIndex: 1,
length: 10,
name: null,
},
});
/* 操作 */
//
const handleRowClassName = ({ rowIndex }) => (rowIndex === 2 ? 'ignore-drag-sort' : '');
// sortablejs
const handleRowSort = (newIndex, oldIndex, e) => {
console.info('sort', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
//
const handleRowAdd = (newIndex, oldIndex, tableCode, done, e) => {
console.info('add', e);
if (tableCode === 'child') {
done(queryState.list.find((item) => item.id === '1').childList[oldIndex]);
}
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
//
const handleRowRemove = (newIndex, oldIndex, e) => {
console.info('remove', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
//
const handleRowClone = (newIndex, oldIndex, e) => {
console.info('clone', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
/* 子表格操作 */
const handleChildrenRowSort = (newIndex, oldIndex, e) => {
console.info('child sort', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
const handleChildrenRowAdd = (newIndex, oldIndex, tableCode, done, e) => {
console.info('child add', e);
if (tableCode === 'parent') {
done(queryState.list[oldIndex]);
}
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
const handleChildrenRowRemove = (newIndex, oldIndex, e) => {
console.info('child remove', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
const handleChildrenRowClone = (newIndex, oldIndex, e) => {
console.info('child clone', e);
ElMessage.success(`排序从 ${oldIndex} 变成了 ${newIndex} `);
};
return {
...toRefs(commonState),
...toRefs(queryState),
handleRowClassName,
handleRowSort,
handleRowAdd,
handleRowRemove,
handleRowClone,
handleChildrenRowSort,
handleChildrenRowAdd,
handleChildrenRowRemove,
handleChildrenRowClone,
};
},
});
</script>

@ -0,0 +1,75 @@
<template>
<div class="container">
<div class="box">
<h1 class="title">404</h1>
</div>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const store = useStore();
const refsForm = ref(null);
const loading = ref(false);
const form = reactive({
phone: '',
password: '',
verifyCode: '',
});
const lastTime = computed(() => store.state.local.lastSendMessageTime);
const waitTime = ref(60);
const sendStep = ref(60);
const rules = reactive({
phone: [{ required: true, message: '请输入手机号码' }],
password: [{ required: true, message: '请输入登录密码' }],
verifyCode: [{ required: true, message: '请输入验证码' }],
});
waitTime.value = Math.max(0, unref(sendStep) - Math.ceil((new Date().getTime() - unref(lastTime)) / 1000));
setInterval(() => {
waitTime.value = Math.max(0, unref(sendStep) - Math.ceil((new Date().getTime() - unref(lastTime)) / 1000));
}, 1000);
const handleSms = async () => {
if (form.phone) {
loading.value = true;
await store.dispatch('auth/sms', { phone: form.phone, type: 1 });
loading.value = false;
} else {
proxy.$message.warning('请输入手机号码');
}
};
const handleLogin = async () => {
loading.value = true;
try {
await unref(refsForm).validate();
await store.dispatch('auth/login', form);
} catch (e) {
console.info('取消登录', e);
}
loading.value = false;
};
</script>
<style lang="less" scoped>
.container {
width: 100%;
height: 100%;
background: #000 url('~/global/login-bgp.png') center center / 100% 100% no-repeat;
display: flex;
justify-content: center;
align-items: center;
color: @color-white;
.box {
width: 420px;
padding: 30px;
background: #1a2229;
border-radius: 10px;
.title {
font-size: 48px;
text-align: center;
}
}
}
</style>

@ -0,0 +1,131 @@
<template>
<div class="mask">
<div class="box">
<div class="title">登录</div>
<el-form ref="refsForm" class="content" :model="form" :rules="rules" size="large">
<el-form-item prop="phone">
<el-input v-model="form.phone" class="ghost" placeholder="请输入手机号码" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" class="ghost" placeholder="请输入登录密码" />
</el-form-item>
<el-form-item prop="verifyCode">
<div class="flex">
<el-input v-model="form.verifyCode" class="ghost" placeholder="请输入验证码" />
<el-button class="ghost" :disabled="waitTime > 0" @click="handleSms" :loading="loading">
{{ waitTime ? waitTime + 'S' : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-button class="block" @click="handleLogin" :loading="loading">立即登录</el-button>
</el-form>
</div>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const store = useStore();
const refsForm = ref(null);
const loading = ref(false);
const form = reactive({
phone: '',
password: '',
verifyCode: '',
});
const lastTime = computed(() => store.state.local.lastSendMessageTime);
const waitTime = ref(60);
const sendStep = ref(60);
const rules = reactive({
phone: [{ required: true, message: '请输入手机号码' }],
password: [{ required: true, message: '请输入登录密码' }],
verifyCode: [{ required: true, message: '请输入验证码' }],
});
waitTime.value = Math.max(0, unref(sendStep) - Math.ceil((new Date().getTime() - unref(lastTime)) / 1000));
setInterval(() => {
waitTime.value = Math.max(0, unref(sendStep) - Math.ceil((new Date().getTime() - unref(lastTime)) / 1000));
}, 1000);
const handleSms = async () => {
if (form.phone) {
loading.value = true;
await store.dispatch('auth/sms', { phone: form.phone, type: 1 });
loading.value = false;
} else {
proxy.$message.warning('请输入手机号码');
}
};
const handleLogin = async () => {
loading.value = true;
try {
await unref(refsForm).validate();
await store.dispatch('auth/login', form);
} catch (e) {
console.info('取消登录', e);
}
loading.value = false;
};
</script>
<style lang="less" scoped>
.mask {
width: 100%;
height: 100vh;
background: #000 url('~/global/login-bgp.png') center center / 100% 100% no-repeat;
display: flex;
justify-content: center;
align-items: center;
color: @color-white;
.box {
width: 420px;
padding: 30px;
background: #1a2229;
border-radius: @layout-border-radius;
.title {
margin-bottom: @layout-space-super;
font-size: @layout-h1;
text-align: center;
}
.content {
.flex {
width: 100%;
display: flex;
+ .flex {
margin-top: @layout-space;
}
.el-button {
width: 30%;
margin-left: @layout-space;
color: #fff;
}
}
:deep(.el-input) {
input {
color: #fff;
}
}
:deep(.ghost) {
background: none;
* {
background: none;
}
}
.block {
font-size: 16px;
font-weight: normal;
color: #ffffff;
width: 100%;
height: 40px;
text-align: center;
line-height: 40px;
background: linear-gradient(0deg, #fb3a4e, #ff6272);
border-radius: 4px;
border: none;
cursor: pointer;
}
}
}
}
</style>

@ -0,0 +1,38 @@
<template>
<div class="container">
<h1>{{ $route.name }}</h1>
<h1>
<el-icon name="app-store" size="30" />
<span>马士兵严选</span>
<el-icon name="msb" svg size="30"></el-icon>
<el-icon name="vue" svg color="red" size="30" />
<el-icon name="Avatar" color="red" size="30" />
</h1>
<p>count:{{ count }}, double count:{{ doubleCount }}</p>
<el-button type="primary" @click="handleAdd"></el-button>
<el-button type="danger" @click="handleClear"></el-button>
<br />
<el-date-picker></el-date-picker>
{{ form }}
<el-input v-model="form.msg" />
</div>
</template>
<script setup>
const store = useStore();
const count = computed(() => store.state.demo.count);
const doubleCount = computed(() => store.getters['demo/doubleCount']);
const handleAdd = () => {
store.commit('demo/add');
};
const handleClear = () => {
store.dispatch('demo/clear');
};
const form = reactive({ msg: '123' });
</script>
<style lang="less" scoped>
.container {
height: 2000px !important;
}
</style>

@ -0,0 +1,7 @@
<template>
<div>权限列表</div>
</template>
<script setup></script>
<style lang="less" scoped></style>

@ -0,0 +1,7 @@
<template>
<div>角色列表</div>
</template>
<script setup></script>
<style lang="less" scoped></style>

@ -0,0 +1,136 @@
<template>
<TableList
v-loading="loading"
code="DemoList"
title="用户"
:data="list"
:total="total"
:config="config"
@search="handleSearch"
@create="handleCreate"
@remove="handleRemove"
>
<template #search>
<el-form inline>
<el-form-item prop="username" label="用户名">
<el-input v-model="state.condition.username" />
</el-form-item>
</el-form>
</template>
</TableList>
</template>
<script setup lang="jsx">
const store = useStore();
const loading = computed(() => store.state.user.loading);
const list = computed(() => store.state.user.list);
const total = computed(() => store.state.user.total);
const opts = computed(() => store.state.user.opts);
const state = reactive({
condition: {
username: null,
},
});
const handleSearch = (page) => {
store.dispatch('user/search', { ...page, ...state.condition });
};
const handleCreate = (row) => {
alert('create ' + row.id);
};
const handleUpdate = (row) => {
alert('update ' + row.id);
};
const handleRemove = (rows) => {
alert('delete ' + rows.map((item) => item.id).join(','));
};
const handleDetail = (row) => {
alert('detail ' + row.id);
};
const handleEnabled = (row, enabled) => {
alert((enabled ? 'enabled ' : 'disabled ') + row.id);
};
const config = reactive({
columns: [
{
type: 'selection',
fixed: 'left',
width: 60,
},
{
label: '用户名',
prop: 'username',
minWidth: 160,
fixed: 'left',
},
{
label: '昵称',
prop: 'nickname',
minWidth: 160,
},
{
label: '性别',
slots: {
default: ({ row }) => unref(opts).sex.find((item) => item.value === row.sex)?.label,
},
width: 100,
},
{
label: '头像',
slots: {
default: ({ row }) => <ElImage src={row.avatar} alt="用户头像" />,
},
width: 100,
},
{
label: '登录时间',
slots: {
default: ({ row }) => dayjs(row.loginTime).format('YYYY-MM-DD HH:mm:ss'),
},
width: 180,
},
{
label: '状态',
slots: {
default: ({ row }) => (
<ElSwitch
modelValue={row.enabled}
active-text="启用"
inactive-text="禁用"
active-value={true}
inactive-value={false}
onInput={(e) => handleEnabled(row, e)}
/>
),
},
width: 160,
},
{
label: '操作',
fixed: 'right',
slots: {
default: ({ row }) => (
<div>
<ElButton type="text" onClick={() => handleUpdate(row)}>
编辑
</ElButton>
<ElButton type="text" onClick={() => handleRemove([row])}>
删除
</ElButton>
<ElDropdown
opts={[
{
label: '详情',
onClick: () => handleDetail(row),
},
]}
/>
</div>
),
},
width: 200,
},
],
});
</script>
<style lang="less" scoped></style>

@ -0,0 +1,102 @@
import { UserConfigExport, ConfigEnv } from 'vite';
import { resolve } from 'path';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import { createStyleImportPlugin, ElementPlusResolve as ElementPlusStyleResolve } from 'vite-plugin-style-import';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import globalStyle from '@originjs/vite-plugin-global-style';
import removeConsole from 'vite-plugin-remove-console';
import legacy from '@vitejs/plugin-legacy';
export default ({ command, mode }: ConfigEnv): UserConfigExport => {
console.info('command', command);
console.info('mode', mode);
return {
server: {
host: '0.0.0.0',
port: 3000,
open: false,
proxy: {
'/api': {
target: 'https://gateway-test.mashibing.cn', // 测试地址
// target: 'https://gateway.mashibing.cn', // 预发地址
// target: 'https://gateway.mashibing.com', // 生产环境
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
resolve: {
alias: [
{
find: '@',
replacement: resolve(process.cwd(), 'src'),
},
{
find: '~',
replacement: resolve(process.cwd(), 'src/assets'),
},
],
},
plugins: [
vue(),
vueJsx(),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), 'src/icons/svg')],
symbolId: 'icon-[dir]-[name]',
}),
createStyleImportPlugin({
include: ['**/*.js', '**/*.ts', '**/*.tsx', '**/*.jsx', '**/*.vue'],
resolves: [ElementPlusStyleResolve()],
}),
AutoImport({
include: [/\.[jt]sx?$/, /\.vue\??/],
imports: [
'vue',
'vuex',
'vue-router',
{
axios: [
['default', 'axios'], // import { default as axios } from 'axios',
],
},
{ lodash: [['*', '_']] },
{ dayjs: [['*', 'dayjs']] },
],
eslintrc: {
enabled: true,
filepath: './src/.eslintrc.json',
globalsPropValue: true,
},
dts: 'src/auto-imports.d.ts',
}),
Components({
dirs: ['src/components'],
extensions: ['vue', 'jsx', 'tsx', 'js', 'ts'],
deep: true,
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
include: [/\.tsx$/, /\.jsx$/, /\.ts$/, /\.js$/, /\.vue$/, /\.vue\?vue/],
exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/],
}),
removeConsole(),
globalStyle({
sourcePath: 'src/styles',
}),
legacy({
targets: ['defaults', 'not IE 11'],
}),
],
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
};
};
Loading…
Cancel
Save