This commit is contained in:
ygx
2026-03-07 20:11:25 +08:00
parent 8fab91c5c7
commit f7bbb5ee46
74 changed files with 6540 additions and 2636 deletions

View File

@@ -1,196 +0,0 @@
<template>
<div class="card-wrap">
<a-card v-if="loading" :bordered="false" hoverable>
<slot name="skeleton"></slot>
</a-card>
<a-card v-else :bordered="false" hoverable>
<a-space align="start">
<a-avatar v-if="icon" :size="24" style="margin-right: 8px; background-color: #626aea">
<icon-filter />
</a-avatar>
<a-card-meta>
<template #title>
<a-typography-text style="margin-right: 10px">
{{ title }}
</a-typography-text>
<template v-if="showTag">
<a-tag v-if="open && isExpires === false" size="small" color="green">
<template #icon>
<icon-check-circle-fill />
</template>
<span>{{ tagText }}</span>
</a-tag>
<a-tag v-else-if="isExpires" size="small" color="red">
<template #icon>
<icon-check-circle-fill />
</template>
<span>{{ expiresTagText }}</span>
</a-tag>
</template>
</template>
<template #description>
{{ description }}
<slot></slot>
</template>
</a-card-meta>
</a-space>
<template #actions>
<a-switch v-if="actionType === 'switch'" v-model="open" />
<a-space v-else-if="actionType === 'button'">
<template v-if="isExpires">
<a-button type="outline" @click="renew">
{{ expiresText }}
</a-button>
</template>
<template v-else>
<a-button v-if="open" @click="handleToggle">
{{ closeTxt }}
</a-button>
<a-button v-else-if="!open" type="outline" @click="handleToggle">
{{ openTxt }}
</a-button>
</template>
</a-space>
<div v-else>
<a-space>
<a-button @click="toggle(false)">
{{ closeTxt }}
</a-button>
<a-button type="primary" @click="toggle(true)">
{{ openTxt }}
</a-button>
</a-space>
</div>
</template>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useToggle } from '@vueuse/core'
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
actionType: {
type: String,
default: '',
},
defaultValue: {
type: Boolean,
default: false,
},
openTxt: {
type: String,
default: '',
},
closeTxt: {
type: String,
default: '',
},
expiresText: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
showTag: {
type: Boolean,
default: true,
},
tagText: {
type: String,
default: '',
},
expires: {
type: Boolean,
default: false,
},
expiresTagText: {
type: String,
default: '',
},
})
const [open, toggle] = useToggle(props.defaultValue)
const handleToggle = () => {
toggle()
}
const isExpires = ref(props.expires)
const renew = () => {
isExpires.value = false
}
</script>
<style scoped lang="less">
.card-wrap {
height: 100%;
transition: all 0.3s;
border: 1px solid var(--color-neutral-3);
border-radius: 4px;
&:hover {
transform: translateY(-4px);
// box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.1);
}
:deep(.arco-card) {
height: 100%;
border-radius: 4px;
.arco-card-body {
height: 100%;
.arco-space {
width: 100%;
height: 100%;
.arco-space-item {
height: 100%;
&:last-child {
flex: 1;
}
.arco-card-meta {
height: 100%;
display: flex;
flex-flow: column;
.arco-card-meta-content {
flex: 1;
.arco-card-meta-description {
margin-top: 8px;
color: rgb(var(--gray-6));
line-height: 20px;
font-size: 12px;
}
}
.arco-card-meta-footer {
margin-top: 0;
}
}
}
}
}
}
:deep(.arco-card-meta-title) {
display: flex;
align-items: center;
// To prevent the shaking
line-height: 28px;
}
:deep(.arco-skeleton-line) {
&:last-child {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.content') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col :xs="12" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="list-col">
<div class="card-wrap empty-wrap">
<a-card :bordered="false" hoverable>
<a-result :status="null" :title="$t('cardList.content.action')">
<template #icon>
<icon-plus style="font-size: 20px" />
</template>
</a-result>
</a-card>
</div>
</a-col>
<a-col v-for="item in renderData" :key="item.id" class="list-col" :xs="12" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6">
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:icon="item.icon"
:open-txt="$t('cardList.content.inspection')"
:close-txt="$t('cardList.content.delete')"
:show-tag="false"
>
<a-descriptions style="margin-top: 16px" :data="item.data" layout="inline-horizontal" :column="2" />
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['50%', '50%', '100%', '40%']" :rows="4" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryInspectionList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(3).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryInspectionList, defaultValue)
</script>
<style scoped lang="less">
.card-wrap {
height: 100%;
transition: all 0.3s;
border: 1px solid var(--color-neutral-3);
&:hover {
transform: translateY(-4px);
}
:deep(.arco-card-meta-description) {
color: rgb(var(--gray-6));
.arco-descriptions-item-label-inline {
font-weight: normal;
font-size: 12px;
color: rgb(var(--gray-6));
}
.arco-descriptions-item-value-inline {
color: rgb(var(--gray-8));
}
}
}
.empty-wrap {
height: 200px;
border-radius: 4px;
:deep(.arco-card) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
.arco-result-title {
color: rgb(var(--gray-6));
}
}
}
</style>

View File

@@ -1,48 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.preset') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col
v-for="item in renderData"
:key="item.id"
:xs="12"
:sm="12"
:md="12"
:lg="6"
:xl="6"
:xxl="6"
class="list-col"
style="min-height: 140px"
>
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:tag-text="$t('cardList.preset.tag')"
>
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['100%', '40%']" :rows="2" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryRulesPresetList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(6).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryRulesPresetList, defaultValue)
</script>
<style scoped lang="less"></style>

View File

@@ -1,54 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.service') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col
v-for="item in renderData"
:key="item.id"
:xs="12"
:sm="12"
:md="12"
:lg="6"
:xl="6"
:xxl="6"
class="list-col"
style="min-height: 162px"
>
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:expires="item.expires"
:open-txt="$t('cardList.service.open')"
:close-txt="$t('cardList.service.cancel')"
:expires-text="$t('cardList.service.renew')"
:tag-text="$t('cardList.service.tag')"
:expires-tag-text="$t('cardList.service.expiresTag')"
:icon="item.icon"
>
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['100%', '40%', '100%']" :rows="3" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryTheServiceList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(4).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryTheServiceList, defaultValue)
</script>
<style scoped lang="less"></style>

View File

@@ -1,92 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', 'menu.list.cardList']" />
<a-row :gutter="20" align="stretch">
<a-col :span="24">
<a-card class="general-card" :title="$t('menu.list.cardList')">
<a-row justify="space-between">
<a-col :span="24">
<a-tabs :default-active-tab="1" type="rounded">
<a-tab-pane key="1" :title="$t('cardList.tab.title.all')">
<QualityInspection />
<TheService />
<RulesPreset />
</a-tab-pane>
<a-tab-pane key="2" :title="$t('cardList.tab.title.content')">
<QualityInspection />
</a-tab-pane>
<a-tab-pane key="3" :title="$t('cardList.tab.title.service')">
<TheService />
</a-tab-pane>
<a-tab-pane key="4" :title="$t('cardList.tab.title.preset')">
<RulesPreset />
</a-tab-pane>
</a-tabs>
</a-col>
<a-input-search
:placeholder="$t('cardList.searchInput.placeholder')"
style="width: 240px; position: absolute; top: 60px; right: 20px"
/>
</a-row>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import QualityInspection from './components/quality-inspection.vue'
import TheService from './components/the-service.vue'
import RulesPreset from './components/rules-preset.vue'
</script>
<script lang="ts">
export default {
name: 'Card',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
:deep(.arco-list-content) {
overflow-x: hidden;
}
:deep(.arco-card-meta-title) {
font-size: 14px;
}
}
:deep(.arco-list-col) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
:deep(.arco-list-item) {
width: 33%;
}
:deep(.block-title) {
margin: 0 0 12px 0;
font-size: 14px;
}
:deep(.list-wrap) {
// min-height: 140px;
.list-row {
align-items: stretch;
.list-col {
margin-bottom: 16px;
}
}
:deep(.arco-space) {
width: 100%;
.arco-space-item {
&:last-child {
flex: 1;
}
}
}
}
</style>

View File

@@ -1,19 +0,0 @@
export default {
'menu.list.cardList': 'Card List',
'cardList.tab.title.all': 'All',
'cardList.tab.title.content': 'Quality Inspection',
'cardList.tab.title.service': 'The service',
'cardList.tab.title.preset': 'Rules Preset',
'cardList.searchInput.placeholder': 'Search',
'cardList.enable': 'Enable',
'cardList.disable': 'Disable',
'cardList.content.delete': 'Delete',
'cardList.content.inspection': 'Inspection',
'cardList.content.action': 'Click Create Qc Content queue',
'cardList.service.open': 'Open',
'cardList.service.cancel': 'Cancel',
'cardList.service.renew': 'Contract of service',
'cardList.service.tag': 'Opened',
'cardList.service.expiresTag': 'Expired',
'cardList.preset.tag': 'Enable',
}

View File

@@ -1,19 +0,0 @@
export default {
'menu.list.cardList': '卡片列表',
'cardList.tab.title.all': '全部',
'cardList.tab.title.content': '内容质检',
'cardList.tab.title.service': '开通服务',
'cardList.tab.title.preset': '规则预置',
'cardList.searchInput.placeholder': '搜索',
// 'cardList.statistic.enable': '已启用',
// 'cardList.statistic.disable': '未启用',
'cardList.content.delete': '删除',
'cardList.content.inspection': '质检',
'cardList.content.action': '点击创建质检内容队列',
'cardList.service.open': '开通服务',
'cardList.service.cancel': '取消服务',
'cardList.service.renew': '续约服务',
'cardList.service.tag': '已开通',
'cardList.service.expiresTag': '已过期',
'cardList.preset.tag': '已启用',
}

View File

@@ -1,179 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
import { ServiceRecord } from '@/api/list'
const qualityInspectionList: ServiceRecord[] = [
{
id: 1,
name: 'quality',
title: '视频类-历史导入',
description: '2021-10-12 00:00:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
{
id: 2,
name: 'quality',
title: '图文类-图片版权',
description: '2021-12-11 18:30:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
{
id: 3,
name: 'quality',
title: '图文类-高清图片',
description: '2021-10-15 08:10:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
]
const theServiceList: ServiceRecord[] = [
{
id: 1,
icon: 'code',
title: '漏斗分析',
description: '用户行为分析之漏斗分析模型是企业实现精细化运营、进行用户行为分析的重要数据分析模型。',
enable: true,
actionType: 'button',
},
{
id: 2,
icon: 'edit',
title: '用户分布',
description: '快速诊断用户人群,地域细分情况,了解数据分布的集中度,以及主要的数据分布的区间段是什么。',
enable: true,
actionType: 'button',
expires: true,
},
{
id: 3,
icon: 'user',
title: '资源分发',
description: '移动端动态化资源分发解决方案。提供稳定大流量服务支持、灵活定制的分发圈选规则,通过离线化预加载。',
enable: false,
actionType: 'button',
},
{
id: 4,
icon: 'user',
title: '用户画像分析',
description: '用户画像就是将典型用户信息标签化,根据用户特征、业务场景和用户行为等信息,构建一个标签化的用户模型。',
enable: true,
actionType: 'button',
},
]
const rulesPresetList: ServiceRecord[] = [
{
id: 1,
title: '内容屏蔽规则',
description: '用户在执行特定的内容分发任务时,可使用内容屏蔽规则根据特定标签,过滤内容集合。',
enable: true,
actionType: 'switch',
},
{
id: 2,
title: '内容置顶规则',
description: '该规则支持用户在执行特定内容分发任务时,对固定的几条内容置顶。',
enable: true,
actionType: 'switch',
},
{
id: 3,
title: '内容加权规则',
description: '选定内容加权规则后可自定义从不同内容集合获取内容的概率。',
enable: false,
actionType: 'switch',
},
{
id: 4,
title: '内容分发规则',
description: '内容分发时对某些内容需要固定在C端展示的位置。',
enable: true,
actionType: 'switch',
},
{
id: 5,
title: '违禁内容识别',
description: '精准识别赌博、刀枪、毒品、造假、贩假等违规物品和违规行为。',
enable: false,
actionType: 'switch',
},
{
id: 6,
title: '多语言文字符号识别',
description: '精准识别英语、维语、藏语、蒙古语、朝鲜语等多种语言以及emoji表情形态的语义识别。',
enable: false,
actionType: 'switch',
},
]
setupMock({
setup() {
// Quality Inspection
Mock.mock(new RegExp('/api/list/quality-inspection'), () => {
return successResponseWrap(
qualityInspectionList.map((_, index) => ({
...qualityInspectionList[index % qualityInspectionList.length],
id: Mock.Random.guid(),
}))
)
})
// the service
Mock.mock(new RegExp('/api/list/the-service'), () => {
return successResponseWrap(
theServiceList.map((_, index) => ({
...theServiceList[index % theServiceList.length],
id: Mock.Random.guid(),
}))
)
})
// rules preset
Mock.mock(new RegExp('/api/list/rules-preset'), () => {
return successResponseWrap(
rulesPresetList.map((_, index) => ({
...rulesPresetList[index % rulesPresetList.length],
id: Mock.Random.guid(),
}))
)
})
},
})

View File

@@ -0,0 +1,326 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', '公共组件Demo']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="公共组件演示"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<!-- 工具栏左侧按钮 -->
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增
</a-button>
<a-button status="success" @click="handleBatchDelete">
<template #icon>
<icon-delete />
</template>
批量删除
</a-button>
</template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 表格自定义列状态 -->
<template #status="{ record }">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 表格自定义列头像 -->
<template #avatar="{ record }">
<a-avatar :style="{ backgroundColor: record.avatarColor }">
{{ record.name.charAt(0) }}
</a-avatar>
</template>
<!-- 表格自定义列操作 -->
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm content="确定要删除吗?" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
// 定义表格数据类型
interface UserRecord {
id: number
name: string
email: string
department: string
role: string
status: 'active' | 'inactive'
avatarColor: string
createdAt: string
}
// 模拟数据生成
const generateMockData = (count: number): UserRecord[] => {
const departments = ['技术部', '产品部', '运营部', '市场部', '财务部']
const roles = ['管理员', '普通用户', '访客']
const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十']
const colors = ['#165DFF', '#0FC6C2', '#722ED1', '#F53F3F', '#FF7D00', '#00B42A']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: names[i % names.length] + (Math.floor(i / names.length) || ''),
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
role: roles[i % roles.length],
status: i % 3 === 0 ? 'inactive' : 'active',
avatarColor: colors[i % colors.length],
createdAt: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
}))
}
// 状态管理
const loading = ref(false)
const tableData = ref<UserRecord[]>([])
const formModel = ref({
name: '',
department: '',
status: '',
email: '',
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'name',
label: '用户名',
type: 'input',
placeholder: '请输入用户名',
},
{
field: 'email',
label: '邮箱',
type: 'input',
placeholder: '请输入邮箱',
},
{
field: 'department',
label: '部门',
type: 'select',
placeholder: '请选择部门',
options: [
{ label: '技术部', value: '技术部' },
{ label: '产品部', value: '产品部' },
{ label: '运营部', value: '运营部' },
{ label: '市场部', value: '市场部' },
{ label: '财务部', value: '财务部' },
],
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
],
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '头像',
dataIndex: 'avatar',
slotName: 'avatar',
width: 80,
},
{
title: '用户名',
dataIndex: 'name',
width: 120,
},
{
title: '邮箱',
dataIndex: 'email',
width: 200,
},
{
title: '部门',
dataIndex: 'department',
width: 120,
},
{
title: '角色',
dataIndex: 'role',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 120,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 200,
fixed: 'right',
},
])
// 模拟异步获取数据
const fetchData = async () => {
loading.value = true
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
let data = generateMockData(86)
// 根据搜索条件过滤
if (formModel.value.name) {
data = data.filter(item => item.name.includes(formModel.value.name))
}
if (formModel.value.email) {
data = data.filter(item => item.email.includes(formModel.value.email))
}
if (formModel.value.department) {
data = data.filter(item => item.department === formModel.value.department)
}
if (formModel.value.status) {
data = data.filter(item => item.status === formModel.value.status)
}
// 更新分页
pagination.total = data.length
// 分页截取
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
loading.value = false
}
// 事件处理
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
name: '',
department: '',
status: '',
email: '',
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleAdd = () => {
Message.info('新增功能开发中...')
}
const handleBatchDelete = () => {
Message.warning('请先选择要删除的数据')
}
const handleView = (record: UserRecord) => {
Message.info(`查看用户:${record.name}`)
}
const handleEdit = (record: UserRecord) => {
Message.info(`编辑用户:${record.name}`)
}
const handleDelete = (record: UserRecord) => {
Message.success(`已删除用户:${record.name}`)
}
// 初始化加载数据
fetchData()
</script>
<script lang="ts">
export default {
name: 'SearchTableDemo',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -1,198 +1,95 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', 'menu.list.searchTable']" />
<a-card class="general-card" :title="$t('menu.list.searchTable')">
<a-row>
<a-col :flex="1">
<a-form :model="formModel" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item field="number" :label="$t('searchTable.form.number')">
<a-input v-model="formModel.number" :placeholder="$t('searchTable.form.number.placeholder')" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="name" :label="$t('searchTable.form.name')">
<a-input v-model="formModel.name" :placeholder="$t('searchTable.form.name.placeholder')" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="contentType" :label="$t('searchTable.form.contentType')">
<a-select
v-model="formModel.contentType"
:options="contentTypeOptions"
:placeholder="$t('searchTable.form.selectDefault')"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="filterType" :label="$t('searchTable.form.filterType')">
<a-select
v-model="formModel.filterType"
:options="filterTypeOptions"
:placeholder="$t('searchTable.form.selectDefault')"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="createdTime" :label="$t('searchTable.form.createdTime')">
<a-range-picker v-model="formModel.createdTime" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="status" :label="$t('searchTable.form.status')">
<a-select v-model="formModel.status" :options="statusOptions" :placeholder="$t('searchTable.form.selectDefault')" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-col>
<a-divider style="height: 84px" direction="vertical" />
<a-col :flex="'86px'" style="text-align: right">
<a-space direction="vertical" :size="18">
<a-button type="primary" @click="search">
<template #icon>
<icon-search />
</template>
{{ $t('searchTable.form.search') }}
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="renderData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:title="$t('menu.list.searchTable')"
:search-button-text="$t('searchTable.form.search')"
:reset-button-text="$t('searchTable.form.reset')"
:download-button-text="$t('searchTable.operation.download')"
:refresh-tooltip-text="$t('searchTable.actions.refresh')"
:density-tooltip-text="$t('searchTable.actions.density')"
:column-setting-tooltip-text="$t('searchTable.actions.columnSetting')"
@search="search"
@reset="reset"
@page-change="onPageChange"
@refresh="search"
>
<!-- 工具栏左侧插槽 -->
<template #toolbar-left>
<a-button type="primary">
<template #icon>
<icon-plus />
</template>
{{ $t('searchTable.operation.create') }}
</a-button>
<a-upload action="/">
<template #upload-button>
<a-button>
{{ $t('searchTable.operation.import') }}
</a-button>
<a-button @click="reset">
<template #icon>
<icon-refresh />
</template>
{{ $t('searchTable.form.reset') }}
</a-button>
</a-space>
</a-col>
</a-row>
<a-divider style="margin-top: 0" />
<a-row style="margin-bottom: 16px">
<a-col :span="12">
<a-space>
<a-button type="primary">
<template #icon>
<icon-plus />
</template>
{{ $t('searchTable.operation.create') }}
</a-button>
<a-upload action="/">
<template #upload-button>
<a-button>
{{ $t('searchTable.operation.import') }}
</a-button>
</template>
</a-upload>
</a-space>
</a-col>
<a-col :span="12" style="display: flex; align-items: center; justify-content: end">
<a-button>
<template #icon>
<icon-download />
</template>
{{ $t('searchTable.operation.download') }}
</a-button>
<a-tooltip :content="$t('searchTable.actions.refresh')">
<div class="action-icon" @click="search"><icon-refresh size="18" /></div>
</a-tooltip>
<a-dropdown @select="handleSelectDensity">
<a-tooltip :content="$t('searchTable.actions.density')">
<div class="action-icon"><icon-line-height size="18" /></div>
</a-tooltip>
<template #content>
<a-doption v-for="item in densityList" :key="item.value" :value="item.value" :class="{ active: item.value === size }">
<span>{{ item.name }}</span>
</a-doption>
</template>
</a-dropdown>
<a-tooltip :content="$t('searchTable.actions.columnSetting')">
<a-popover trigger="click" position="bl" @popup-visible-change="popupVisibleChange">
<div class="action-icon"><icon-settings size="18" /></div>
<template #content>
<div id="tableSetting">
<div v-for="(item, index) in showColumns" :key="item.dataIndex" class="setting">
<div style="margin-right: 4px; cursor: move">
<icon-drag-arrow />
</div>
<div>
<a-checkbox v-model="item.checked" @change="handleChange($event, item as TableColumnData, index)"></a-checkbox>
</div>
<div class="title">
{{ item.title === '#' ? '序列号' : item.title }}
</div>
</div>
</div>
</template>
</a-popover>
</a-tooltip>
</a-col>
</a-row>
<a-table
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns as TableColumnData[]"
:data="renderData"
:bordered="false"
:size="size"
@page-change="onPageChange"
>
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #contentType="{ record }">
<a-space>
<a-avatar v-if="record.contentType === 'img'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/581b17753093199839f2e327e726b157.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else-if="record.contentType === 'horizontalVideo'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77721e365eb2ab786c889682cbc721c1.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/ea8b09190046da0ea7e070d83c5d1731.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
{{ $t(`searchTable.form.contentType.${record.contentType}`) }}
</a-space>
</template>
<template #filterType="{ record }">
{{ $t(`searchTable.form.filterType.${record.filterType}`) }}
</template>
<template #status="{ record }">
<span v-if="record.status === 'offline'" class="circle"></span>
<span v-else class="circle pass"></span>
{{ $t(`searchTable.form.status.${record.status}`) }}
</template>
<template #operations>
<a-button v-permission="['admin']" type="text" size="small">
{{ $t('searchTable.columns.operations.view') }}
</a-button>
</template>
</a-table>
</a-card>
</template>
</a-upload>
</template>
<!-- 表格自定义列插槽 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #contentType="{ record }">
<a-space>
<a-avatar v-if="record.contentType === 'img'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/581b17753093199839f2e327e726b157.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else-if="record.contentType === 'horizontalVideo'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77721e365eb2ab786c889682cbc721c1.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/ea8b09190046da0ea7e070d83c5d1731.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
{{ $t(`searchTable.form.contentType.${record.contentType}`) }}
</a-space>
</template>
<template #filterType="{ record }">
{{ $t(`searchTable.form.filterType.${record.filterType}`) }}
</template>
<template #status="{ record }">
<span v-if="record.status === 'offline'" class="circle"></span>
<span v-else class="circle pass"></span>
{{ $t(`searchTable.form.status.${record.status}`) }}
</template>
<template #operations>
<a-button v-permission="['admin']" type="text" size="small">
{{ $t('searchTable.columns.operations.view') }}
</a-button>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive, watch, nextTick } from 'vue'
import { computed, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import useLoading from '@/hooks/loading'
import { queryPolicyList, PolicyRecord, PolicyParams } from '@/api/list'
import { Pagination } from '@/types/global'
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import cloneDeep from 'lodash/cloneDeep'
import Sortable from 'sortablejs'
type SizeProps = 'mini' | 'small' | 'medium' | 'large'
type Column = TableColumnData & { checked?: true }
import type { FormItem } from '@/components/search-form/types'
const generateFormModel = () => {
return {
@@ -208,10 +105,6 @@ const { loading, setLoading } = useLoading(true)
const { t } = useI18n()
const renderData = ref<PolicyRecord[]>([])
const formModel = ref(generateFormModel())
const cloneColumns = ref<Column[]>([])
const showColumns = ref<Column[]>([])
const size = ref<SizeProps>('medium')
const basePagination: Pagination = {
current: 1,
@@ -220,66 +113,7 @@ const basePagination: Pagination = {
const pagination = reactive({
...basePagination,
})
const densityList = computed(() => [
{
name: t('searchTable.size.mini'),
value: 'mini',
},
{
name: t('searchTable.size.small'),
value: 'small',
},
{
name: t('searchTable.size.medium'),
value: 'medium',
},
{
name: t('searchTable.size.large'),
value: 'large',
},
])
const columns = computed<TableColumnData[]>(() => [
{
title: t('searchTable.columns.index'),
dataIndex: 'index',
slotName: 'index',
},
{
title: t('searchTable.columns.number'),
dataIndex: 'number',
},
{
title: t('searchTable.columns.name'),
dataIndex: 'name',
},
{
title: t('searchTable.columns.contentType'),
dataIndex: 'contentType',
slotName: 'contentType',
},
{
title: t('searchTable.columns.filterType'),
dataIndex: 'filterType',
},
{
title: t('searchTable.columns.count'),
dataIndex: 'count',
},
{
title: t('searchTable.columns.createdTime'),
dataIndex: 'createdTime',
},
{
title: t('searchTable.columns.status'),
dataIndex: 'status',
slotName: 'status',
},
{
title: t('searchTable.columns.operations'),
dataIndex: 'operations',
slotName: 'operations',
},
])
const contentTypeOptions = computed<SelectOptionData[]>(() => [
{
label: t('searchTable.form.contentType.img'),
@@ -314,6 +148,91 @@ const statusOptions = computed<SelectOptionData[]>(() => [
value: 'offline',
},
])
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'number',
label: t('searchTable.form.number'),
type: 'input',
placeholder: t('searchTable.form.number.placeholder'),
},
{
field: 'name',
label: t('searchTable.form.name'),
type: 'input',
placeholder: t('searchTable.form.name.placeholder'),
},
{
field: 'contentType',
label: t('searchTable.form.contentType'),
type: 'select',
options: contentTypeOptions.value,
},
{
field: 'filterType',
label: t('searchTable.form.filterType'),
type: 'select',
options: filterTypeOptions.value,
},
{
field: 'createdTime',
label: t('searchTable.form.createdTime'),
type: 'dateRange',
},
{
field: 'status',
label: t('searchTable.form.status'),
type: 'select',
options: statusOptions.value,
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: t('searchTable.columns.index'),
dataIndex: 'index',
slotName: 'index',
},
{
title: t('searchTable.columns.number'),
dataIndex: 'number',
},
{
title: t('searchTable.columns.name'),
dataIndex: 'name',
},
{
title: t('searchTable.columns.contentType'),
dataIndex: 'contentType',
slotName: 'contentType',
},
{
title: t('searchTable.columns.filterType'),
dataIndex: 'filterType',
slotName: 'filterType',
},
{
title: t('searchTable.columns.count'),
dataIndex: 'count',
},
{
title: t('searchTable.columns.createdTime'),
dataIndex: 'createdTime',
},
{
title: t('searchTable.columns.status'),
dataIndex: 'status',
slotName: 'status',
},
{
title: t('searchTable.columns.operations'),
dataIndex: 'operations',
slotName: 'operations',
},
])
const fetchData = async (params: PolicyParams = { current: 1, pageSize: 20 }) => {
setLoading(true)
try {
@@ -342,54 +261,6 @@ fetchData()
const reset = () => {
formModel.value = generateFormModel()
}
const handleSelectDensity = (val: string | number | Record<string, any> | undefined, e: Event) => {
size.value = val as SizeProps
}
const handleChange = (checked: boolean | (string | boolean | number)[], column: Column, index: number) => {
if (!checked) {
cloneColumns.value = showColumns.value.filter((item) => item.dataIndex !== column.dataIndex)
} else {
cloneColumns.value.splice(index, 0, column)
}
}
const exchangeArray = <T extends Array<any>>(array: T, beforeIdx: number, newIdx: number, isDeep = false): T => {
const newArray = isDeep ? cloneDeep(array) : array
if (beforeIdx > -1 && newIdx > -1) {
// 先替换后面的,然后拿到替换的结果替换前面的
newArray.splice(beforeIdx, 1, newArray.splice(newIdx, 1, newArray[beforeIdx]).pop())
}
return newArray
}
const popupVisibleChange = (val: boolean) => {
if (val) {
nextTick(() => {
const el = document.getElementById('tableSetting') as HTMLElement
const sortable = new Sortable(el, {
onEnd(e: any) {
const { oldIndex, newIndex } = e
exchangeArray(cloneColumns.value, oldIndex, newIndex)
exchangeArray(showColumns.value, oldIndex, newIndex)
},
})
})
}
}
watch(
() => columns.value,
(val) => {
cloneColumns.value = cloneDeep(val)
cloneColumns.value.forEach((item, index) => {
item.checked = true
})
showColumns.value = cloneDeep(cloneColumns.value)
},
{ deep: true, immediate: true }
)
</script>
<script lang="ts">
@@ -402,28 +273,4 @@ export default {
.container {
padding: 0 20px 20px 20px;
}
:deep(.arco-table-th) {
&:last-child {
.arco-table-th-item-title {
margin-left: 16px;
}
}
}
.action-icon {
margin-left: 12px;
cursor: pointer;
}
.active {
color: #0960bd;
background-color: #e3f4fc;
}
.setting {
display: flex;
align-items: center;
width: 200px;
.title {
margin-left: 12px;
cursor: pointer;
}
}
</style>

View File

@@ -5,12 +5,12 @@
<div class="login-form-error-msg">{{ errorMessage }}</div>
<a-form ref="loginForm" :model="userInfo" class="login-form" layout="vertical" @submit="handleSubmit">
<a-form-item
field="username"
:rules="[{ required: true, message: $t('login.form.userName.errMsg') }]"
field="account"
:rules="[{ required: true, message: '请输入账号' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input v-model="userInfo.username" :placeholder="$t('login.form.userName.placeholder')">
<a-input v-model="userInfo.account" placeholder="账号">
<template #prefix>
<icon-user />
</template>
@@ -18,11 +18,11 @@
</a-form-item>
<a-form-item
field="password"
:rules="[{ required: true, message: $t('login.form.password.errMsg') }]"
:rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input-password v-model="userInfo.password" :placeholder="$t('login.form.password.placeholder')" allow-clear>
<a-input-password v-model="userInfo.password" placeholder="密码" allow-clear>
<template #prefix>
<icon-lock />
</template>
@@ -44,7 +44,7 @@
</template>
<script lang="ts" setup>
import type { LoginData } from '@/api/user'
import type { LoginData } from '@/api/types'
import useLoading from '@/hooks/loading'
import { useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
@@ -62,11 +62,11 @@ const userStore = useUserStore()
const loginConfig = useStorage('login-config', {
rememberPassword: true,
username: 'admin', // 演示默认值
password: 'admin', // demo default value
account: '',
password: '',
})
const userInfo = reactive({
username: loginConfig.value.username,
account: loginConfig.value.account,
password: loginConfig.value.password,
})
@@ -85,10 +85,10 @@ const handleSubmit = async ({ errors, values }: { errors: Record<string, Validat
})
Message.success(t('login.form.login.success'))
const { rememberPassword } = loginConfig.value
const { username, password } = values
const { account, password } = values
// 实际生产环境需要进行加密存储。
// The actual production environment requires encrypted storage.
loginConfig.value.username = rememberPassword ? username : ''
loginConfig.value.account = rememberPassword ? account : ''
loginConfig.value.password = rememberPassword ? password : ''
} catch (err) {
errorMessage.value = (err as Error).message

View File

@@ -1,25 +1,6 @@
<template>
<div class="login-bg">
<div class="container">
<div class="scan-login-btn" @click="showQr = true">
<svg class="scan-icon" viewBox="0 0 24 24" width="20" height="20">
<rect x="3" y="3" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="14" y="3" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="14" y="14" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="3" y="14" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
</svg>
<span>扫码登录</span>
</div>
<a-modal v-model:visible="showQr" title="扫码登录" :footer="false" width="320px">
<div class="qr-modal-content">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=login-demo"
alt="二维码"
style="width: 200px; height: 200px; display: block; margin: 0 auto"
/>
<div style="text-align: center; margin-top: 12px; color: #888">请使用微信/钉钉等扫码登录</div>
</div>
</a-modal>
<div class="logo">
<img alt="logo" src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" />
<div class="logo-text">智能运维管理系统</div>
@@ -128,32 +109,4 @@ export default defineComponent({
background: transparent;
}
}
.scan-login-btn {
position: absolute;
top: 24px;
right: 32px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
background: #f4f8ff;
color: #00308f;
border-radius: 18px;
padding: 6px 16px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 8px 0 rgba(31, 38, 135, 0.08);
transition: background 0.2s;
&:hover {
background: #e0e7ff;
}
.scan-icon {
display: inline-block;
vertical-align: middle;
}
}
.qr-modal-content {
padding: 12px 0 0 0;
}
</style>

View File

@@ -4,8 +4,8 @@ export default {
'login.form.password.errMsg': '密码不能为空',
'login.form.login.errMsg': '登录出错,轻刷新重试',
'login.form.login.success': '欢迎使用',
'login.form.userName.placeholder': '用户名admin',
'login.form.password.placeholder': '密码admin',
'login.form.userName.placeholder': '请输入用户名',
'login.form.password.placeholder': '请输入密码',
'login.form.rememberPassword': '记住密码',
'login.form.forgetPassword': '忘记密码',
'login.form.login': '登录',

View File

@@ -1,8 +1,8 @@
<template>
<div class="content">
<a-result class="result" status="404" :subtitle="'not found'"></a-result>
<a-result class="result" status="404" :subtitle="'未找到'"></a-result>
<div class="operation-row">
<a-button key="back" type="primary" @click="back">back</a-button>
<a-button key="back" type="primary" @click="back">返回</a-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,351 @@
<template>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
class="menu-form"
@submit-success="handleSubmit"
>
<!-- 基本信息 -->
<a-divider orientation="left">基本信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="code" label="菜单编码" :rules="[{ required: true, message: '请输入菜单编码' }]">
<a-input v-model="formData.code" placeholder="请输入菜单编码,如: menu_management" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="type" label="菜单类型">
<a-select v-model="formData.type">
<a-option :value="1">{{ formData.parent_id ? '子菜单' : '根菜单' }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="title" label="菜单名称" :rules="[{ required: true, message: '请输入菜单名称' }]">
<a-input v-model="formData.title" placeholder="请输入中文菜单名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="title_en" label="英文名称" :rules="[{ required: true, message: '请输入英文名称' }]">
<a-input v-model="formData.title_en" placeholder="请输入英文菜单名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="menu_path" label="路由路径" :rules="[{ required: true, message: '请输入路由路径' }]">
<a-input v-model="formData.menu_path" placeholder="请输入路由路径,如: /ops/menu-management" />
</a-form-item>
</a-col>
<!-- 根菜单才显示图标选择 -->
<a-col v-if="!parentId" :span="12">
<a-form-item field="menu_icon" label="菜单图标" :rules="[{ required: true, message: '请选择菜单图标' }]">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item field="description" label="菜单描述">
<a-textarea v-model="formData.description" placeholder="请输入菜单描述" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</a-col>
</a-row>
<!-- 网页嵌入配置 -->
<a-divider orientation="left">网页嵌入配置</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">是否为嵌入网页</span>
</a-form-item>
</a-col>
<a-col v-if="formData.is_web_page" :span="24">
<a-form-item field="web_url" label="网页地址">
<a-input v-model="formData.web_url" placeholder="请输入要嵌入的网页URL" />
</a-form-item>
</a-col>
</a-row>
<!-- 操作按钮 -->
<div class="form-actions">
<a-space>
<a-button type="primary" html-type="submit" :loading="loading">
保存
</a-button>
<a-button @click="$emit('cancel')">
取消
</a-button>
</a-space>
</div>
<!-- 图标选择器 -->
<a-modal
v-model:visible="iconPickerVisible"
title="选择图标"
:footer="false"
width="600px"
>
<div class="icon-search">
<a-input v-model="iconSearch" placeholder="搜索图标" allow-clear>
<template #prefix><icon-search /></template>
</a-input>
</div>
<div class="icon-grid">
<div
v-for="icon in filteredIcons"
:key="icon"
class="icon-item"
:class="{ 'is-selected': formData.menu_icon === icon }"
@click="selectIcon(icon)"
>
<component :is="getIconComponent(icon)" :size="24" class="icon-preview" />
<span class="icon-name">{{ icon }}</span>
</div>
</div>
</a-modal>
</a-form>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue'
import type { MenuRouteRequest, MenuNode } from '../types'
import { COMMON_ICONS, iconNames, getIconComponent } from '../menuIcons'
import { IconSearch } from '@tabler/icons-vue'
const props = defineProps<{
mode: 'add' | 'edit' | 'add_child'
parentId?: number | null
initialValues?: MenuNode | null
parentName?: string
}>()
const emit = defineEmits<{
(e: 'save', data: MenuRouteRequest): void
(e: 'cancel'): void
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const iconPickerVisible = ref(false)
const iconSearch = ref('')
// 表单数据
const formData = ref<MenuRouteRequest>({
code: '',
description: '',
menu_icon: '',
menu_path: '',
parent_id: undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
// 编辑时需要的额外字段
id: undefined,
identity: undefined,
app_id: undefined,
sort_key: undefined,
created_at: undefined,
})
// 图标列表
const iconList = iconNames
// 过滤图标
const filteredIcons = computed(() => {
if (!iconSearch.value) return iconList
return iconList.filter(icon =>
icon.toLowerCase().includes(iconSearch.value.toLowerCase())
)
})
// 表单验证规则
const rules = {
code: [{ required: true, message: '请输入菜单编码' }],
title: [{ required: true, message: '请输入菜单名称' }],
title_en: [{ required: true, message: '请输入英文名称' }],
menu_path: [{ required: true, message: '请输入路由路径' }],
}
// 重置表单
const resetForm = () => {
formData.value = {
code: '',
description: '',
menu_icon: '',
menu_path: '',
parent_id: props.parentId || undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
id: undefined,
identity: undefined,
app_id: undefined,
sort_key: undefined,
created_at: undefined,
}
formRef.value?.clearValidate()
}
// 监听 mode 和 initialValues 变化
watch(
() => [props.mode, props.initialValues] as const,
([mode, initialValues]) => {
if (mode === 'add' || mode === 'add_child') {
resetForm()
} else if (mode === 'edit' && initialValues) {
// 编辑模式:保留所有原始数据,只更新表单字段
formData.value = {
id: initialValues.id,
identity: initialValues.identity,
app_id: initialValues.app_id ?? 2,
sort_key: initialValues.sort_key,
created_at: initialValues.created_at,
code: initialValues.code || '',
description: initialValues.description || '',
menu_icon: initialValues.menu_icon || '',
menu_path: initialValues.menu_path || '',
parent_id: initialValues.parent_id,
title: initialValues.title || '',
title_en: initialValues.title_en || '',
type: initialValues.type || 1,
is_web_page: initialValues.is_web_page || false,
web_url: initialValues.web_url || '',
}
}
},
{ immediate: true }
)
// 监听父ID变化
watch(() => props.parentId, (val) => {
if (props.mode === 'add_child') {
formData.value.parent_id = val || undefined
}
}, { immediate: true })
// 选择图标
const selectIcon = (icon: string) => {
formData.value.menu_icon = icon
iconPickerVisible.value = false
iconSearch.value = ''
}
// 提交表单
const handleSubmit = async () => {
try {
loading.value = true
const valid = await formRef.value?.validate()
if (valid) return
emit('save', { ...formData.value })
} catch (error) {
console.error('Form validation error:', error)
} finally {
loading.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'MenuForm',
}
</script>
<style scoped lang="less">
.menu-form {
.switch-label {
margin-left: 8px;
color: var(--color-text-2);
}
.form-actions {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
text-align: right;
}
}
.icon-search {
margin-bottom: 16px;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
&.is-selected {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
position: relative;
&::after {
content: '✓';
position: absolute;
top: 4px;
right: 4px;
color: rgb(var(--primary-6));
font-size: 12px;
}
}
.icon-preview {
font-size: 24px;
color: var(--color-text-1);
}
.icon-name {
margin-top: 4px;
font-size: 10px;
color: var(--color-text-3);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="menu-tree">
<MenuTreeItem
v-for="node in nodes"
:key="node.id"
:node="node"
:level="1"
:expanded-keys="expandedKeys"
:selected-key="selectedKey"
:dragging-node="draggingNode"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
@drag-start="$emit('drag-start', $event)"
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
<!-- 根级别拖放区域 -->
<div
v-if="draggingNode"
class="root-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDropToRoot"
>
<icon-plus-circle /> 拖放到此处设为根级菜单
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import MenuTreeItem from './MenuTreeItem.vue'
import type { MenuNode } from '../types'
const props = defineProps<{
nodes: MenuNode[]
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }): void
}>()
const isDragOver = ref(false)
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDropToRoot = () => {
isDragOver.value = false
if (props.draggingNode) {
// 拖放到根级别
emit('drop', {
dragNode: props.draggingNode,
targetNode: null,
position: 'root'
})
}
}
</script>
<script lang="ts">
export default {
name: 'MenuTree',
}
</script>
<style scoped lang="less">
.menu-tree {
user-select: none;
}
.root-drop-zone {
margin-top: 8px;
padding: 16px;
border: 2px dashed var(--color-border-2);
border-radius: 4px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover, &.drag-over {
border-color: rgb(var(--primary-6));
color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
}
</style>

View File

@@ -0,0 +1,274 @@
<template>
<div class="menu-tree-item">
<div
class="menu-item-content"
:class="{
'is-selected': selectedKey === node.id,
'is-hovered': isHovered,
'drag-over': isDragOver
}"
:style="{ paddingLeft: `${level * 16}px` }"
draggable="true"
@click="handleSelect"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@dragend="handleDragEnd"
>
<!-- 展开/折叠按钮 -->
<span class="expand-btn" @click.stop="handleToggle">
<icon-right v-if="!isExpanded && hasChildren" />
<icon-down v-else-if="isExpanded && hasChildren" />
<span v-else class="placeholder"></span>
</span>
<!-- 拖拽手柄 -->
<span class="drag-handle">
<icon-drag-dot-vertical />
</span>
<!-- 菜单图标 -->
<span v-if="node.menu_icon" class="menu-icon">
<component :is="getIcon(node.menu_icon)" />
</span>
<!-- 菜单标题 -->
<span class="menu-title">{{ node.title }}</span>
<!-- 分组标识 -->
<a-tag v-if="!node.menu_path" size="small" color="gray">分组</a-tag>
<!-- 操作按钮 -->
<span v-show="isHovered" class="action-btns">
<!-- 只有一级菜单根菜单才显示添加按钮 -->
<a-tooltip v-if="level === 1" content="添加子菜单">
<a-button type="text" size="mini" @click.stop="handleAddChild">
<template #icon><icon-plus /></template>
</a-button>
</a-tooltip>
<a-tooltip content="删除">
<a-button type="text" size="mini" status="danger" @click.stop="handleDelete">
<template #icon><icon-delete /></template>
</a-button>
</a-tooltip>
</span>
</div>
<!-- 子菜单 -->
<div v-if="isExpanded && hasChildren" class="menu-children">
<MenuTreeItem
v-for="child in node.children"
:key="child.id"
:node="child"
:level="level + 1"
:expanded-keys="expandedKeys"
:selected-key="selectedKey"
:dragging-node="draggingNode"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
@drag-start="$emit('drag-start', $event)"
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import type { MenuNode } from '../types'
import { getIconComponent } from '../menuIcons'
const props = defineProps<{
node: MenuNode
level: number
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }): void
}>()
const isHovered = ref(false)
const isDragOver = ref(false)
const isExpanded = computed(() => props.expandedKeys.has(props.node.id))
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
const isDraggingThis = computed(() => props.draggingNode?.id === props.node.id)
const handleToggle = () => {
if (hasChildren.value) {
emit('toggle', props.node.id)
}
}
const handleSelect = () => {
emit('select', props.node)
}
const handleAddChild = () => {
emit('add-child', props.node.id)
}
const handleDelete = () => {
emit('delete', props.node)
}
// 拖拽相关
const handleDragStart = (e: DragEvent) => {
e.dataTransfer!.effectAllowed = 'move'
emit('drag-start', props.node)
// 添加拖拽样式
const target = e.target as HTMLElement
setTimeout(() => {
target.style.opacity = '0.5'
}, 0)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = false
if (props.draggingNode && props.draggingNode.id !== props.node.id) {
// 判断放置位置
const rect = (e.target as HTMLElement).getBoundingClientRect()
const y = e.clientY - rect.top
const height = rect.height
let position: 'before' | 'after' | 'inside' = 'inside'
if (y < height * 0.25) {
position = 'before'
} else if (y > height * 0.75) {
position = 'after'
} else {
position = 'inside'
}
emit('drop', {
dragNode: props.draggingNode,
targetNode: props.node,
position
})
}
}
const handleDragEnd = (e: DragEvent) => {
const target = e.target as HTMLElement
target.style.opacity = '1'
emit('drag-end')
}
// 获取图标组件
const getIcon = (iconName: string) => {
return getIconComponent(iconName)
}
</script>
<script lang="ts">
export default {
name: 'MenuTreeItem',
}
</script>
<style scoped lang="less">
.menu-tree-item {
.menu-item-content {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
&:hover, &.is-hovered {
background-color: var(--color-fill-2);
}
&.is-selected {
background-color: rgb(var(--primary-1));
color: rgb(var(--primary-6));
}
&.drag-over {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
.expand-btn {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
color: var(--color-text-3);
.placeholder {
width: 16px;
height: 16px;
}
}
.drag-handle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
color: var(--color-text-4);
cursor: grab;
&:hover {
color: var(--color-text-2);
}
}
.menu-icon {
margin-right: 8px;
color: var(--color-text-2);
}
.menu-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.action-btns {
display: flex;
gap: 4px;
}
}
.menu-children {
// 子菜单样式
}
}
</style>

View File

@@ -0,0 +1,463 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<a-row :gutter="16" class="menu-container">
<!-- 左侧: 菜单树 -->
<a-col :xs="24" :md="10" :lg="8">
<a-card class="menu-tree-card" :bordered="false">
<!-- 头部操作区 -->
<template #title>
<div class="card-header">
<span>菜单管理</span>
<a-button type="primary" size="small" @click="handleAddRootMenu">
<template #icon><icon-plus /></template>
添加根菜单
</a-button>
</div>
</template>
<!-- 提示信息 -->
<a-alert class="menu-tip" type="info" :show-icon="true">
点击菜单项可编辑悬停显示操作按钮
</a-alert>
<!-- 菜单树 -->
<a-spin :loading="loading" class="menu-tree-spin">
<div class="menu-tree-wrapper">
<MenuTree
v-if="rootItems.length > 0"
:nodes="rootItems"
:expanded-keys="expandedKeys"
:selected-key="selectedNode?.id || null"
:dragging-node="draggingNode"
@toggle="handleToggleExpand"
@select="handleSelectNode"
@add-child="handleAddChildMenu"
@delete="handleDeleteClick"
@drag-start="handleDragStart"
@drag-end="handleDragEnd"
@drop="handleDrop"
/>
<a-empty v-else description="暂无菜单数据" />
</div>
</a-spin>
</a-card>
</a-col>
<!-- 右侧: 编辑表单 -->
<a-col :xs="24" :md="14" :lg="16">
<a-card class="menu-edit-card" :bordered="false">
<template #title>
<div class="edit-header">
<span>{{ getEditTitle() }}</span>
<span v-if="isAddingChild || (isEditing && selectedNode?.parent_id)" class="parent-info">
父菜单:
<a-tag color="arcoblue">
{{ isEditing && selectedNode?.parent_id ? getParentMenuName(selectedNode.parent_id) : getParentMenuName(parentId) }}
</a-tag>
</span>
</div>
</template>
<!-- 编辑表单 -->
<MenuForm
v-if="isEditing || isAdding || isAddingChild"
:mode="getEditMode()"
:parent-id="parentId"
:initial-values="selectedNode"
:parent-name="getParentMenuName(parentId)"
@save="handleSaveMenu"
@cancel="handleCancelEdit"
/>
<a-empty v-else description="请从左侧选择一个菜单项进行编辑" class="empty-state" />
</a-card>
</a-col>
</a-row>
<!-- 删除确认对话框 -->
<a-modal
v-model:visible="deleteConfirmVisible"
title="删除确认"
@ok="handleConfirmDelete"
@cancel="deleteConfirmVisible = false"
>
<p>确定要删除菜单 "{{ nodeToDelete?.title }}" </p>
<p v-if="nodeToDelete?.children?.length" class="warning-text">
<icon-exclamation-circle-fill /> 注意该菜单下有子菜单删除后子菜单也会被删除
</p>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { MenuNode, MenuRouteRequest } from './types'
import { fetchMenu, createMenu, modifyMenu, deleteMenu, updateMenuOrder } from '@/api/module/pmn'
import { buildTree } from '@/utils/tree'
import MenuTree from './components/MenuTree.vue'
import MenuForm from './components/MenuForm.vue'
// 状态管理
const loading = ref(false)
const menuItems = ref<MenuRouteRequest[]>([])
const expandedKeys = ref<Set<number>>(new Set())
const selectedNode = ref<MenuNode | null>(null)
// 编辑状态
const isEditing = ref(false)
const isAdding = ref(false)
const isAddingChild = ref(false)
const parentId = ref<number | null>(null)
// 删除确认
const deleteConfirmVisible = ref(false)
const nodeToDelete = ref<MenuNode | null>(null)
// 拖拽状态
const draggingNode = ref<MenuNode | null>(null)
// 计算属性 - 使用公共 buildTree 函数构建树
const treeData = computed(() => buildTree<MenuNode>(menuItems.value as MenuNode[], { orderKey: 'order' }))
const rootItems = computed(() => treeData.value.rootItems)
const itemMap = computed(() => treeData.value.itemMap)
// 加载菜单数据
const loadMenuItems = async () => {
try {
loading.value = true
const res = await fetchMenu({ page: 1, size: 10000 })
if (res?.code === 0) {
menuItems.value = res.details?.data || []
}
} catch (error) {
console.error('Failed to load menu items:', error)
} finally {
loading.value = false
}
}
// 切换节点展开/折叠
const handleToggleExpand = (nodeId: number) => {
if (expandedKeys.value.has(nodeId)) {
expandedKeys.value.delete(nodeId)
} else {
expandedKeys.value.add(nodeId)
}
}
// 选择节点
const handleSelectNode = (node: MenuNode) => {
selectedNode.value = node
isEditing.value = true
isAdding.value = false
isAddingChild.value = false
}
// 添加根菜单
const handleAddRootMenu = () => {
selectedNode.value = null
isEditing.value = false
isAdding.value = true
isAddingChild.value = false
parentId.value = null
}
// 添加子菜单
const handleAddChildMenu = (pId: number) => {
selectedNode.value = null
isEditing.value = false
isAdding.value = false
isAddingChild.value = true
parentId.value = pId
}
// 编辑菜单
const handleEditMenu = (node: MenuNode) => {
selectedNode.value = node
isEditing.value = true
isAdding.value = false
isAddingChild.value = false
parentId.value = node.parent_id || null
}
// 删除点击
const handleDeleteClick = (node: MenuNode) => {
nodeToDelete.value = node
deleteConfirmVisible.value = true
}
// 确认删除
const handleConfirmDelete = async () => {
if (!nodeToDelete.value?.id) return
try {
loading.value = true
await deleteMenu({ id: nodeToDelete.value.id })
Message.success('删除成功')
// 如果删除的是当前选中的节点,清空编辑状态
if (selectedNode.value?.id === nodeToDelete.value.id) {
selectedNode.value = null
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
parentId.value = null
}
deleteConfirmVisible.value = false
nodeToDelete.value = null
await loadMenuItems()
} catch (error) {
console.error('Failed to delete menu:', error)
} finally {
loading.value = false
}
}
// 保存菜单
const handleSaveMenu = async (menuData: MenuRouteRequest) => {
try {
loading.value = true
if (isEditing.value && selectedNode.value?.id) {
await modifyMenu({ ...menuData, id: selectedNode.value.id })
} else {
await createMenu(menuData)
}
Message.success('保存成功')
// 重置状态
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
await loadMenuItems()
} catch (error) {
console.error('Failed to save menu:', error)
} finally {
loading.value = false
}
}
// 取消编辑
const handleCancelEdit = () => {
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
selectedNode.value = null
}
// 获取编辑模式
const getEditMode = (): 'add' | 'edit' | 'add_child' => {
if (isEditing.value) return 'edit'
if (isAddingChild.value) return 'add_child'
return 'add'
}
// 获取编辑区标题
const getEditTitle = () => {
if (isEditing.value && selectedNode.value) {
return `编辑: ${selectedNode.value.title}`
}
if (isAddingChild.value) {
const parentName = getParentMenuName(parentId.value)
return `为 "${parentName}" 添加子菜单`
}
if (isAdding.value) return '添加根菜单'
return '详情'
}
// 获取父菜单名称
const getParentMenuName = (pId?: number | null) => {
const map = itemMap.value
if (!pId || !map.has(pId)) return ''
const item = map.get(pId)
return item?.title || ''
}
// 拖拽开始
const handleDragStart = (node: MenuNode) => {
draggingNode.value = node
}
// 拖拽结束
const handleDragEnd = () => {
draggingNode.value = null
}
// 处理放置
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }) => {
const { dragNode, targetNode, position } = data
if (!dragNode.id) return
try {
loading.value = true
if (position === 'root' && !targetNode) {
// 拖放到根级别
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: null
} as MenuRouteRequest)
} else if (targetNode) {
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.id
} as MenuRouteRequest)
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item, index) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
} else if (item.id !== dragNode.id) {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
}
}
}
Message.success('移动成功')
await loadMenuItems()
} catch (error) {
console.error('Failed to move menu:', error)
Message.error('移动失败')
} finally {
loading.value = false
draggingNode.value = null
}
}
// 递归收集子节点
const collectChildren = (
items: MenuRouteRequest[],
parentId: number,
sortList: { pmn_id: number; sort_key: number }[],
getSortKey: () => number
) => {
const children = items
.filter((item: MenuRouteRequest) => item.parent_id === parentId)
.sort((a: MenuRouteRequest, b: MenuRouteRequest) => (a.order || 0) - (b.order || 0))
children.forEach((item: MenuRouteRequest) => {
if (item.id) {
sortList.push({ pmn_id: item.id, sort_key: getSortKey() })
collectChildren(items, item.id, sortList, getSortKey)
}
})
}
// 初始加载
onMounted(() => {
loadMenuItems()
})
</script>
<script lang="ts">
export default {
name: 'MenuManagement',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.menu-container {
height: calc(100vh - 300px);
> div {
height: 100%;
}
}
.menu-tree-card,
.menu-edit-card {
height: 100%;
display: flex;
flex-direction: column;
:deep(.arco-card-body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.menu-tip {
margin-bottom: 16px;
}
.menu-tree-spin {
flex: 1;
overflow: hidden;
}
.menu-tree-wrapper {
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.edit-header {
display: flex;
align-items: center;
gap: 12px;
.parent-info {
font-size: 12px;
color: var(--color-text-3);
font-weight: normal;
}
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.warning-text {
color: rgb(var(--warning-6));
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,296 @@
// 菜单图标集合 - 使用 @tabler/icons-vue
import type { Component } from 'vue';
import {
IconDashboard, IconHome, IconSettings, IconUser, IconUsers, IconUserCircle,
IconBuilding, IconFileText, IconCoin, IconChartBar, IconChartPie, IconList,
IconShield, IconWorld, IconLanguage, IconHelp, IconInfoCircle, IconFolder,
IconDatabase, IconMenu, IconCircle, IconSearch, IconFolderOff,
IconAlertTriangle,
// Business & Finance
Icon2fa, IconAbacus, IconAccessPoint, IconActivity, IconAd, IconAdjustments,
IconAlarm, IconAlertOctagon, IconAnalyze, IconAward, IconBarcode, IconBuildingBank,
IconCalculator, IconCalendar, IconCash, IconChartInfographic, IconCreditCard,
// Communication & Social
IconMail, IconMessage, IconMessages, IconPhone, IconShare, IconSocial,
IconBrandFacebook, IconBrandTwitter, IconBrandLinkedin, IconBrandInstagram,
IconBrandWhatsapp, IconBrandTelegram, IconBrandSkype, IconBrandZoom,
// Development & Technology
IconCode, IconBraces, IconBug, IconDeviceDesktop,
IconDeviceLaptop, IconDeviceMobile, IconDeviceTablet, IconGitBranch, IconTerminal,
IconServer, IconWifi, IconBrandGithub, IconBrandVscode, IconBrandDocker,
IconCloud,
// Documents & Files
IconFile, IconFiles, IconFileZip, IconFile3d,
IconFileDownload, IconFileUpload, IconFolderPlus, IconBook,
IconBooks, IconBookmark, IconClipboard, IconNotes, IconPrinter,
// Interface Elements
IconCheckbox, IconForms, IconLayout,
IconMenu2, IconRadio, IconSelect, IconSwitch, IconTable,
IconToggleLeft, IconToggleRight, IconWindowMaximize,
// Media & Entertainment
IconCamera, IconPhoto, IconVideo, IconMusic, IconPlayerPlay, IconPlayerPause,
IconPlayerStop, IconMovie, IconMicrophone, IconVolume, IconHeadphones,
IconBrush, IconPalette, IconPencil, IconPaint, IconColorPicker,
// Navigation & Maps
IconMap, IconMapPin, IconCompass, IconDirection, IconGps, IconLocation,
IconNavigation, IconRoute, IconRoad, IconTrain, IconPlane, IconCar,
IconBike, IconWalk, IconRun, IconSwimming,
// Shopping & E-commerce
IconShoppingCart, IconShoppingBag, IconBasket, IconGift, IconDiscount,
IconTag, IconTags, IconReceipt, IconTruck, IconPackage, IconBox,
// Weather & Nature
IconSun, IconMoon, IconCloudRain, IconCloudSnow, IconWind,
IconTemperature, IconUmbrella, IconPlant, IconTree, IconLeaf, IconFlower,
// Additional UI Elements
IconEdit, IconCopy, IconArrowBack, IconArrowForward, IconRefresh, IconRotate,
IconZoomIn, IconZoomOut, IconFilter, IconSortAscending,
IconSortDescending, IconColumns, IconLayoutGrid,
// Security & Access
IconLock, IconKey, IconPassword, IconFingerprint, IconShieldLock,
IconCertificate, IconId, IconBadge,
// Time & Date
IconClock, IconCalendarEvent, IconCalendarTime, IconCalendarStats,
IconHourglass, IconHistory, IconTimeline, IconAlarmPlus,
// Health & Medical
IconHeartbeat, IconStethoscope, IconPill, IconVirus, IconVaccine,
IconEmergencyBed, IconAmbulance, IconMedicalCross, IconDna,
// Analytics & Data
IconChartArea, IconChartArrows, IconChartCandle,
IconChartLine, IconPresentationAnalytics,
IconReportAnalytics, IconTrendingUp, IconTrendingDown,
IconSend, IconUpload, IconPlug, IconNotebook, IconTools, IconPoint,
} from '@tabler/icons-vue';
// 预选图标列表
export const COMMON_ICONS: Record<string, Component> = {
// 常用图标
Dashboard: IconDashboard,
Home: IconHome,
Settings: IconSettings,
AccountCircle: IconUserCircle,
People: IconUsers,
Person: IconUser,
Groups: IconUsers,
Store: IconBuilding,
Description: IconFileText,
AttachMoney: IconCoin,
BarChart: IconChartBar,
Insights: IconChartPie,
Security: IconShield,
Public: IconWorld,
Language: IconLanguage,
Help: IconHelp,
Folder: IconFolder,
Storage: IconDatabase,
Menu: IconMenu,
Link: IconCircle,
Search: IconSearch,
List: IconList,
Building: IconBuilding,
World: IconWorld,
Coin: IconCoin,
Users: IconUsers,
// Business & Finance
TwoFactor: Icon2fa,
Abacus: IconAbacus,
AccessPoint: IconAccessPoint,
Activity: IconActivity,
Advertisement: IconAd,
Adjustments: IconAdjustments,
Alarm: IconAlarm,
AlertOctagon: IconAlertOctagon,
Analyze: IconAnalyze,
Award: IconAward,
Barcode: IconBarcode,
Bank: IconBuildingBank,
Calculator: IconCalculator,
Calendar: IconCalendar,
Cash: IconCash,
CreditCard: IconCreditCard,
// Communication & Social
Mail: IconMail,
Message: IconMessage,
Messages: IconMessages,
Phone: IconPhone,
Share: IconShare,
Social: IconSocial,
Facebook: IconBrandFacebook,
Twitter: IconBrandTwitter,
LinkedIn: IconBrandLinkedin,
Instagram: IconBrandInstagram,
WhatsApp: IconBrandWhatsapp,
Telegram: IconBrandTelegram,
Skype: IconBrandSkype,
Zoom: IconBrandZoom,
// Development & Technology
Code: IconCode,
Braces: IconBraces,
Bug: IconBug,
Cloud: IconCloud,
Desktop: IconDeviceDesktop,
Laptop: IconDeviceLaptop,
Mobile: IconDeviceMobile,
Tablet: IconDeviceTablet,
Git: IconGitBranch,
Terminal: IconTerminal,
Server: IconServer,
Wifi: IconWifi,
Github: IconBrandGithub,
VSCode: IconBrandVscode,
Docker: IconBrandDocker,
// Documents & Files
File: IconFile,
Files: IconFiles,
FileZip: IconFileZip,
FilePdf: IconFile3d,
FileDownload: IconFileDownload,
FileUpload: IconFileUpload,
FolderPlus: IconFolderPlus,
Book: IconBook,
Books: IconBooks,
Bookmark: IconBookmark,
Clipboard: IconClipboard,
Notes: IconNotes,
Printer: IconPrinter,
// Interface Elements
Checkbox: IconCheckbox,
Forms: IconForms,
Layout: IconLayout,
Menu2: IconMenu2,
Radio: IconRadio,
Select: IconSelect,
Switch: IconSwitch,
Table: IconTable,
ToggleLeft: IconToggleLeft,
ToggleRight: IconToggleRight,
WindowMaximize: IconWindowMaximize,
// Media & Entertainment
Camera: IconCamera,
Photo: IconPhoto,
Video: IconVideo,
Music: IconMusic,
Play: IconPlayerPlay,
Pause: IconPlayerPause,
Stop: IconPlayerStop,
Movie: IconMovie,
Microphone: IconMicrophone,
Volume: IconVolume,
Headphones: IconHeadphones,
Brush: IconBrush,
Palette: IconPalette,
Pencil: IconPencil,
Paint: IconPaint,
ColorPicker: IconColorPicker,
// Navigation & Maps
Map: IconMap,
MapPin: IconMapPin,
Compass: IconCompass,
Direction: IconDirection,
GPS: IconGps,
Location: IconLocation,
Navigation: IconNavigation,
Route: IconRoute,
Road: IconRoad,
Train: IconTrain,
Plane: IconPlane,
Car: IconCar,
Bike: IconBike,
Walk: IconWalk,
Run: IconRun,
Swimming: IconSwimming,
// Shopping & E-commerce
ShoppingCart: IconShoppingCart,
ShoppingBag: IconShoppingBag,
Basket: IconBasket,
Gift: IconGift,
Discount: IconDiscount,
Tag: IconTag,
Tags: IconTags,
Receipt: IconReceipt,
Truck: IconTruck,
Package: IconPackage,
Box: IconBox,
// Weather & Nature
Sun: IconSun,
Moon: IconMoon,
CloudRain: IconCloudRain,
CloudSnow: IconCloudSnow,
Wind: IconWind,
Temperature: IconTemperature,
Umbrella: IconUmbrella,
Plant: IconPlant,
Tree: IconTree,
Leaf: IconLeaf,
Flower: IconFlower,
// Additional UI Elements
Edit: IconEdit,
Copy: IconCopy,
Undo: IconArrowBack,
Redo: IconArrowForward,
Refresh: IconRefresh,
Rotate: IconRotate,
ZoomIn: IconZoomIn,
ZoomOut: IconZoomOut,
Filter: IconFilter,
Sort: IconSortAscending,
SortAscending: IconSortAscending,
SortDescending: IconSortDescending,
Columns: IconColumns,
Grid: IconLayoutGrid,
// Security & Access
Lock: IconLock,
Key: IconKey,
Password: IconPassword,
Fingerprint: IconFingerprint,
ShieldLock: IconShieldLock,
Certificate: IconCertificate,
ID: IconId,
Badge: IconBadge,
// Time & Date
Clock: IconClock,
CalendarEvent: IconCalendarEvent,
CalendarTime: IconCalendarTime,
CalendarStats: IconCalendarStats,
Hourglass: IconHourglass,
History: IconHistory,
Timeline: IconTimeline,
AlarmPlus: IconAlarmPlus,
// Health & Medical
Heartbeat: IconHeartbeat,
Stethoscope: IconStethoscope,
Pill: IconPill,
Virus: IconVirus,
Vaccine: IconVaccine,
EmergencyBed: IconEmergencyBed,
Ambulance: IconAmbulance,
MedicalCross: IconMedicalCross,
DNA: IconDna,
// Analytics & Data
ChartArea: IconChartArea,
ChartArrows: IconChartArrows,
ChartCandle: IconChartCandle,
ChartInfographic: IconChartInfographic,
ChartLine: IconChartLine,
PresentationAnalytics: IconPresentationAnalytics,
ReportAnalytics: IconReportAnalytics,
TrendingUp: IconTrendingUp,
TrendingDown: IconTrendingDown,
Send: IconSend,
InfoCircle: IconInfoCircle,
Upload: IconUpload,
Plug: IconPlug,
Notebook: IconNotebook,
Tools: IconTools,
Point: IconPoint,
};
// 导出图标名称列表
export const iconNames = Object.keys(COMMON_ICONS);
// 导出类型
export type IconName = keyof typeof COMMON_ICONS;
// 获取图标组件
export const getIconComponent = (iconName: string) => {
return COMMON_ICONS[iconName];
};

View File

@@ -0,0 +1,14 @@
// 从API导入类型
import type { MenuItem as MenuItemType } from '@/api/module/pmn'
// 菜单路由请求接口
export interface MenuRouteRequest extends MenuItemType {}
// 菜单节点类型
export interface MenuNode extends MenuRouteRequest {
id: number
children: MenuNode[]
}
// 图标名称类型
export type IconName = string

View File

@@ -0,0 +1,270 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="系统日志"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<!-- 表格自定义列日志级别 -->
<template #level="{ record }">
<a-tag :color="getLevelColor(record.level)">
{{ record.level }}
</a-tag>
</template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 表格自定义列操作 -->
<template #operations="{ record }">
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
// 定义表格数据类型
interface LogRecord {
id: number
level: string
module: string
content: string
operator: string
ip: string
createdAt: string
}
// 模拟数据生成
const generateMockData = (count: number): LogRecord[] => {
const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG']
const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证']
const operators = ['管理员', '张三', '李四', '系统', '定时任务']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
level: levels[i % levels.length],
module: modules[i % modules.length],
content: `日志内容描述 ${i + 1}`,
operator: operators[i % operators.length],
ip: `192.168.${Math.floor(i / 255)}.${i % 255}`,
createdAt: new Date(Date.now() - i * 3600000).toLocaleString('zh-CN'),
}))
}
// 状态管理
const loading = ref(false)
const tableData = ref<LogRecord[]>([])
const formModel = ref({
level: '',
module: '',
operator: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'level',
label: '日志级别',
type: 'select',
placeholder: '请选择日志级别',
options: [
{ label: 'INFO', value: 'INFO' },
{ label: 'WARN', value: 'WARN' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'DEBUG', value: 'DEBUG' },
],
},
{
field: 'module',
label: '模块',
type: 'select',
placeholder: '请选择模块',
options: [
{ label: '用户管理', value: '用户管理' },
{ label: '权限管理', value: '权限管理' },
{ label: '系统配置', value: '系统配置' },
{ label: '数据备份', value: '数据备份' },
{ label: '登录认证', value: '登录认证' },
],
},
{
field: 'operator',
label: '操作人',
type: 'input',
placeholder: '请输入操作人',
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '日志级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
},
{
title: '模块',
dataIndex: 'module',
width: 120,
},
{
title: '日志内容',
dataIndex: 'content',
ellipsis: true,
tooltip: true,
},
{
title: '操作人',
dataIndex: 'operator',
width: 120,
},
{
title: 'IP地址',
dataIndex: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 100,
fixed: 'right',
},
])
// 获取日志级别颜色
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
INFO: 'arcoblue',
WARN: 'orange',
ERROR: 'red',
DEBUG: 'gray',
}
return colorMap[level] || 'gray'
}
// 模拟异步获取数据
const fetchData = async () => {
loading.value = true
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
let data = generateMockData(100)
// 根据搜索条件过滤
if (formModel.value.level) {
data = data.filter(item => item.level === formModel.value.level)
}
if (formModel.value.module) {
data = data.filter(item => item.module === formModel.value.module)
}
if (formModel.value.operator) {
data = data.filter(item => item.operator.includes(formModel.value.operator))
}
// 更新分页
pagination.total = data.length
// 分页截取
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
loading.value = false
}
// 事件处理
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
level: '',
module: '',
operator: '',
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleView = (record: LogRecord) => {
Message.info(`查看日志详情:${record.id}`)
}
// 初始化加载数据
fetchData()
</script>
<script lang="ts">
export default {
name: 'SystemLogs',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestActivity')">
<template #extra>
<a-link>{{ $t('userInfo.viewAll') }}</a-link>
</template>
<a-list :bordered="false">
<a-list-item v-for="activity in activityList" :key="activity.id" action-layout="horizontal">
<a-skeleton v-if="loading" :loading="loading" :animation="true" class="skeleton-item">
<a-row :gutter="6">
<a-col :span="2">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="22">
<a-skeleton-line :widths="['40%', '100%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta v-else :title="activity.title" :description="activity.description">
<template #avatar>
<a-avatar>
<img :src="activity.avatar" />
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { queryLatestActivity, LatestActivity } from '@/api/user-center'
import useLoading from '@/hooks/loading'
const { loading, setLoading } = useLoading(true)
const activityList = ref<LatestActivity[]>(new Array(7).fill({}))
const fetchData = async () => {
try {
const { data } = await queryLatestActivity()
activityList.value = data
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
.latest-activity {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.general-card :deep(.arco-list-item) {
padding-left: 0;
border-bottom: none;
.arco-list-item-meta-content {
flex: 1;
padding-bottom: 27px;
border-bottom: 1px solid var(--color-neutral-3);
}
.arco-list-item-meta-avatar {
padding-bottom: 27px;
}
.skeleton-item {
margin-top: 10px;
padding-bottom: 20px;
border-bottom: 1px solid var(--color-neutral-3);
}
}
</style>

View File

@@ -1,27 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestNotification')">
<a-skeleton v-if="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-result v-else status="404">
<template #subtitle>
{{ $t('userInfo.nodata') }}
</template>
</a-result>
</a-card>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading'
const { loading, setLoading } = useLoading(true)
setTimeout(() => {
setLoading(false)
}, 500)
</script>
<style lang="less" scoped>
:deep(.arco-result) {
padding: 40px 32px 108px;
}
</style>

View File

@@ -1,82 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.myProject')">
<template #extra>
<a-link>{{ $t('userInfo.showMore') }}</a-link>
</template>
<a-row :gutter="16">
<a-col
v-for="(project, index) in projectList"
:key="index"
:xs="12"
:sm="12"
:md="12"
:lg="12"
:xl="8"
:xxl="8"
class="my-project-item"
>
<a-card>
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-space v-else direction="vertical">
<a-typography-text bold>{{ project.name }}</a-typography-text>
<a-typography-text type="secondary">
{{ project.description }}
</a-typography-text>
<a-space>
<a-avatar-group :size="24">
{{ project.contributors }}
<a-avatar v-for="(contributor, idx) in project.contributors" :key="idx" :size="32">
<img alt="avatar" :src="contributor.avatar" />
</a-avatar>
</a-avatar-group>
<a-typography-text type="secondary">{{ project.peopleNumber }}</a-typography-text>
</a-space>
</a-space>
</a-card>
</a-col>
</a-row>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyProjectList, MyProjectRecord } from '@/api/user-center'
import useRequest from '@/hooks/request'
const defaultValue = Array(6).fill({} as MyProjectRecord)
const { loading, response: projectList } = useRequest<MyProjectRecord[]>(queryMyProjectList, defaultValue)
</script>
<style scoped lang="less">
:deep(.arco-card-body) {
min-height: 128px;
padding-bottom: 0;
}
.my-project {
&-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&-title {
margin-top: 0 !important;
margin-bottom: 18px !important;
}
&-list {
display: flex;
justify-content: space-between;
}
&-item {
// padding-right: 16px;
margin-bottom: 16px;
&:last-child {
padding-right: 0;
}
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<a-card
class="general-card"
:title="$t('userInfo.tab.title.team')"
:header-style="{ paddingBottom: '18px' }"
:body-style="{ paddingBottom: '12px' }"
>
<a-list :bordered="false">
<a-list-item v-for="team in teamList" :key="team.id" action-layout="horizontal">
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-row :gutter="6">
<a-col :span="6">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="16">
<a-skeleton-line :widths="['100%', '40%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta v-else :title="team.name">
<template #avatar>
<a-avatar>
<img :src="team.avatar" />
</a-avatar>
</template>
<template #description>{{ team.peopleNumber }}</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyTeamList, MyTeamRecord } from '@/api/user-center'
import useRequest from '@/hooks/request'
const defaultValue: MyTeamRecord[] = new Array(4).fill({})
const { loading, response: teamList } = useRequest<MyTeamRecord[]>(queryMyTeamList, defaultValue)
</script>
<style scoped lang="less">
.general-card {
height: 356px;
.arco-list-item {
height: 72px;
padding-left: 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-neutral-3);
&:last-child {
border-bottom: none;
}
.arco-list-item-meta {
padding: 0;
}
}
}
</style>

View File

@@ -1,68 +0,0 @@
<template>
<div class="header">
<a-space :size="12" direction="vertical" align="center">
<a-avatar :size="64">
<template #trigger-icon>
<icon-camera />
</template>
<img :src="userInfo.avatar" />
</a-avatar>
<a-typography-title :heading="6" style="margin: 0">
{{ userInfo.name }}
</a-typography-title>
<div class="user-msg">
<a-space :size="18">
<div>
<icon-user />
<a-typography-text>{{ userInfo.jobName }}</a-typography-text>
</div>
<div>
<icon-home />
<a-typography-text>
{{ userInfo.organizationName }}
</a-typography-text>
</div>
<div>
<icon-location />
<a-typography-text>{{ userInfo.locationName }}</a-typography-text>
</div>
</a-space>
</div>
</a-space>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store'
const userInfo = useUserStore()
</script>
<style scoped lang="less">
.header {
display: flex;
align-items: center;
justify-content: center;
height: 204px;
color: var(--gray-10);
background: url(//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/41c6b125cc2e27021bf7fcc9a9b1897c.svg~tplv-49unhts6dw-image.image) no-repeat;
background-size: cover;
border-radius: 4px;
:deep(.arco-avatar-trigger-icon-button) {
color: rgb(var(--arcoblue-6));
:deep(.arco-icon) {
vertical-align: -1px;
}
}
.user-msg {
.arco-icon {
color: rgb(var(--gray-10));
}
.arco-typography {
margin-left: 6px;
}
}
}
</style>

View File

@@ -1,87 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.user', 'menu.user.info']" />
<UserInfoHeader />
<div class="content">
<div class="content-left">
<a-grid :cols="24" :col-gap="16" :row-gap="16">
<a-grid-item :span="24">
<MyProject />
</a-grid-item>
<a-grid-item :span="24">
<LatestActivity />
</a-grid-item>
</a-grid>
</div>
<div class="content-right">
<a-grid :cols="24" :row-gap="16">
<a-grid-item :span="24">
<MyTeam />
</a-grid-item>
<a-grid-item class="panel" :span="24">
<LatestNotification />
</a-grid-item>
</a-grid>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import UserInfoHeader from './components/user-info-header.vue'
import LatestNotification from './components/latest-notification.vue'
import MyProject from './components/my-project.vue'
import LatestActivity from './components/latest-activity.vue'
import MyTeam from './components/my-team.vue'
</script>
<script lang="ts">
export default {
name: 'Info',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.content {
display: flex;
margin-top: 12px;
&-left {
flex: 1;
margin-right: 16px;
overflow: hidden;
// background-color: var(--color-bg-2);
:deep(.arco-tabs-nav-tab) {
margin-left: 16px;
}
}
&-right {
width: 332px;
}
.tab-pane-wrapper {
padding: 0 16px 16px 16px;
}
}
</style>
<style lang="less" scoped>
.mobile {
.content {
display: block;
&-left {
margin-right: 0;
margin-bottom: 16px;
}
&-right {
width: 100%;
}
}
}
</style>

View File

@@ -1,15 +0,0 @@
export default {
'menu.user.info': 'User Info',
'userInfo.editUserInfo': 'Edit Info',
'userInfo.tab.title.overview': 'Overview',
'userInfo.tab.title.project': 'Project',
'userInfo.tab.title.team': 'My Team',
'userInfo.title.latestActivity': 'Latest Activity',
'userInfo.title.latestNotification': 'In-site Notification',
'userInfo.title.myProject': 'My Project',
'userInfo.showMore': 'Show More',
'userInfo.viewAll': 'View All',
'userInfo.nodata': 'No Data',
'userInfo.visits.unit': 'times',
'userInfo.visits.lastMonth': 'Last Month',
}

View File

@@ -1,15 +0,0 @@
export default {
'menu.user.info': '用户信息',
'userInfo.editUserInfo': '编辑信息',
'userInfo.tab.title.overview': '总览',
'userInfo.tab.title.project': '项目',
'userInfo.tab.title.team': '我的团队',
'userInfo.title.latestActivity': '最新动态',
'userInfo.title.latestNotification': '站内通知',
'userInfo.title.myProject': '我的项目',
'userInfo.showMore': '查看更多',
'userInfo.viewAll': '查看全部',
'userInfo.nodata': '暂无数据',
'userInfo.visits.unit': '人次',
'userInfo.visits.lastMonth': '较上月',
}

View File

@@ -1,152 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
setupMock({
setup() {
// 最新项目
Mock.mock(new RegExp('/api/user/my-project/list'), () => {
const contributors = [
{
name: '秦臻宇',
email: 'qingzhenyu@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '于涛',
email: 'yuebao@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '宁波',
email: 'ningbo@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '郑曦月',
email: 'zhengxiyue@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '宁波',
email: 'ningbo@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
},
]
const units = [
{
name: '企业级产品设计系统',
description: 'System',
},
{
name: '智能应用',
description: 'The Volcano Engine',
},
{
name: 'OCR文本识别',
description: 'OCR text recognition',
},
{
name: '内容资源管理',
description: 'Content resource management ',
},
{
name: '今日头条内容管理',
description: 'Toutiao content management',
},
{
name: '智能机器人',
description: 'Intelligent Robot Project',
},
]
return successResponseWrap(
new Array(6).fill(null).map((_item, index) => ({
id: index,
name: units[index].name,
description: units[index].description,
peopleNumber: Mock.Random.natural(10, 1000),
contributors,
}))
)
})
// 最新动态
Mock.mock(new RegExp('/api/user/latest-activity'), () => {
return successResponseWrap(
new Array(7).fill(null).map((_item, index) => ({
id: index,
title: '发布了项目',
description: '企业级产品设计系统',
avatar: '//lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
}))
)
})
// 访问量
Mock.mock(new RegExp('/api/user/visits'), () => {
return successResponseWrap([
{
name: '主页访问量',
visits: 5670,
growth: 206.32,
},
{
name: '项目访问量',
visits: 5670,
growth: 206.32,
},
])
})
// 项目和团队列表
Mock.mock(new RegExp('/api/user/project-and-team/list'), () => {
return successResponseWrap([
{
id: 1,
content: '他创建的项目',
},
{
id: 2,
content: '他参与的项目',
},
{
id: 3,
content: '他创建的团队',
},
{
id: 4,
content: '他加入的团队',
},
])
})
// 团队列表
Mock.mock(new RegExp('/api/user/my-team/list'), () => {
return successResponseWrap([
{
id: 1,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
name: '火智能应用团队',
peopleNumber: Mock.Random.natural(10, 100),
},
{
id: 2,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
name: '企业级产品设计团队',
peopleNumber: Mock.Random.natural(5000, 6000),
},
{
id: 3,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
name: '前端/UE小分队',
peopleNumber: Mock.Random.natural(10, 5000),
},
{
id: 4,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
name: '内容识别插件小分队',
peopleNumber: Mock.Random.natural(10, 100),
},
])
})
},
})

View File

@@ -1,135 +0,0 @@
<template>
<a-form ref="formRef" :model="formData" class="form" :label-col-props="{ span: 8 }" :wrapper-col-props="{ span: 16 }">
<a-form-item
field="email"
:label="$t('userSetting.basicInfo.form.label.email')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.email.required'),
},
]"
>
<a-input v-model="formData.email" :placeholder="$t('userSetting.basicInfo.placeholder.email')" />
</a-form-item>
<a-form-item
field="nickname"
:label="$t('userSetting.basicInfo.form.label.nickname')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.nickname.required'),
},
]"
>
<a-input v-model="formData.nickname" :placeholder="$t('userSetting.basicInfo.placeholder.nickname')" />
</a-form-item>
<a-form-item
field="countryRegion"
:label="$t('userSetting.basicInfo.form.label.countryRegion')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.countryRegion.required'),
},
]"
>
<a-select v-model="formData.countryRegion" :placeholder="$t('userSetting.basicInfo.placeholder.area')">
<a-option value="China">中国</a-option>
</a-select>
</a-form-item>
<a-form-item
field="area"
:label="$t('userSetting.basicInfo.form.label.area')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.area.required'),
},
]"
>
<a-cascader
v-model="formData.area"
:placeholder="$t('userSetting.basicInfo.placeholder.area')"
:options="[
{
label: '北京',
value: 'beijing',
children: [
{
label: '北京',
value: 'beijing',
children: [
{
label: '朝阳',
value: 'chaoyang',
},
],
},
],
},
]"
allow-clear
/>
</a-form-item>
<a-form-item field="address" :label="$t('userSetting.basicInfo.form.label.address')">
<a-input v-model="formData.address" :placeholder="$t('userSetting.basicInfo.placeholder.address')" />
</a-form-item>
<a-form-item
field="profile"
:label="$t('userSetting.basicInfo.form.label.profile')"
:rules="[
{
maxLength: 200,
message: $t('userSetting.form.error.profile.maxLength'),
},
]"
row-class="keep-margin"
>
<a-textarea v-model="formData.profile" :placeholder="$t('userSetting.basicInfo.placeholder.profile')" />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="validate">
{{ $t('userSetting.save') }}
</a-button>
<a-button type="secondary" @click="reset">
{{ $t('userSetting.reset') }}
</a-button>
</a-space>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { FormInstance } from '@arco-design/web-vue/es/form'
import { BasicInfoModel } from '@/api/user-center'
const formRef = ref<FormInstance>()
const formData = ref<BasicInfoModel>({
email: '',
nickname: '',
countryRegion: '',
area: '',
address: '',
profile: '',
})
const validate = async () => {
const res = await formRef.value?.validate()
if (!res) {
// do some thing
// you also can use html-type to submit
}
}
const reset = async () => {
await formRef.value?.resetFields()
}
</script>
<style scoped lang="less">
.form {
width: 540px;
margin: 0 auto;
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userSetting.certification.title.record')" :header-style="{ border: 'none' }">
<a-table v-if="renderData.length" :data="renderData">
<template #columns>
<a-table-column :title="$t('userSetting.certification.columns.certificationType')">
<template #cell>
{{ $t('userSetting.certification.cell.certificationType') }}
</template>
</a-table-column>
<a-table-column :title="$t('userSetting.certification.columns.certificationContent')" data-index="certificationContent" />
<a-table-column :title="$t('userSetting.certification.columns.status')">
<template #cell="{ record }">
<p v-if="record.status === 0">
<span class="circle"></span>
<span>{{ $t('userSetting.certification.cell.auditing') }}</span>
</p>
<p v-if="record.status === 1">
<span class="circle pass"></span>
<span>{{ $t('userSetting.certification.cell.pass') }}</span>
</p>
</template>
</a-table-column>
<a-table-column :title="$t('userSetting.certification.columns.time')" data-index="time" />
<a-table-column :title="$t('userSetting.certification.columns.operation')">
<template #cell="{ record }">
<a-space>
<a-button type="text">
{{ $t('userSetting.certification.button.check') }}
</a-button>
<a-button v-if="record.status === 0" type="text">
{{ $t('userSetting.certification.button.withdraw') }}
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { CertificationRecord } from '@/api/user-center'
defineProps({
renderData: {
type: Array as PropType<CertificationRecord>,
default() {
return []
},
},
})
</script>
<style scoped lang="less">
:deep(.arco-table-th) {
&:last-child {
.arco-table-th-item-title {
margin-left: 16px;
}
}
}
</style>

View File

@@ -1,33 +0,0 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<EnterpriseCertification :enterprise-info="data.enterpriseInfo" />
<CertificationRecords :render-data="data.record" />
</a-spin>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { queryCertification, UnitCertification, EnterpriseCertificationModel } from '@/api/user-center'
import useLoading from '@/hooks/loading'
import EnterpriseCertification from './enterprise-certification.vue'
import CertificationRecords from './certification-records.vue'
const { loading, setLoading } = useLoading(true)
const data = ref<UnitCertification>({
enterpriseInfo: {} as EnterpriseCertificationModel,
record: [],
})
const fetchData = async () => {
try {
const { data: resData } = await queryCertification()
data.value = resData
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less"></style>

View File

@@ -1,106 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userSetting.certification.title.enterprise')" :header-style="{ padding: '0px 20px 16px 20px' }">
<template #extra>
<a-link>{{ $t('userSetting.certification.extra.enterprise') }}</a-link>
</template>
<a-descriptions
class="card-content"
:data="renderData"
:column="3"
align="right"
layout="inline-horizontal"
:label-style="{ fontWeight: 'normal' }"
:value-style="{
width: '200px',
paddingLeft: '8px',
textAlign: 'left',
}"
>
<template #label="{ label }">{{ $t(label) }} :</template>
<template #value="{ value, data }">
<a-tag v-if="data.label === 'userSetting.certification.label.status'" color="green" size="small">已认证</a-tag>
<span v-else>{{ value }}</span>
</template>
</a-descriptions>
</a-card>
</template>
<script lang="ts" setup>
import { PropType, computed } from 'vue'
import { EnterpriseCertificationModel } from '@/api/user-center'
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface'
const props = defineProps({
enterpriseInfo: {
type: Object as PropType<EnterpriseCertificationModel>,
required: true,
},
})
const renderData = computed(() => {
const {
accountType,
status,
time,
legalPerson,
certificateType,
authenticationNumber,
enterpriseName,
enterpriseCertificateType,
organizationCode,
} = props.enterpriseInfo
return [
{
label: 'userSetting.certification.label.accountType',
value: accountType,
},
{
label: 'userSetting.certification.label.status',
value: status,
},
{
label: 'userSetting.certification.label.time',
value: time,
},
{
label: 'userSetting.certification.label.legalPerson',
value: legalPerson,
},
{
label: 'userSetting.certification.label.certificateType',
value: certificateType,
},
{
label: 'userSetting.certification.label.authenticationNumber',
value: authenticationNumber,
},
{
label: 'userSetting.certification.label.enterpriseName',
value: enterpriseName,
},
{
label: 'userSetting.certification.label.enterpriseCertificateType',
value: enterpriseCertificateType,
},
{
label: 'userSetting.certification.label.organizationCode',
value: organizationCode,
},
] as DescData[]
})
</script>
<style scoped lang="less">
.card-content {
width: 100%;
padding: 20px;
background-color: rgb(var(--gray-1));
}
.item-label {
min-width: 98px;
text-align: right;
color: var(--color-text-8);
&:after {
content: ':';
}
}
</style>

View File

@@ -1,120 +0,0 @@
<template>
<a-list :bordered="false">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.password') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.placeholder.password') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.securityQuestion') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph class="tip">
{{ $t('userSetting.SecuritySettings.placeholder.securityQuestion') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.settings') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.phone') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph>已绑定150******50</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.email') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph class="tip">
{{ $t('userSetting.SecuritySettings.placeholder.email') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</template>
<script lang="ts" setup></script>
<style scoped lang="less">
:deep(.arco-list-item) {
border-bottom: none !important;
.arco-typography {
margin-bottom: 20px;
}
.arco-list-item-meta-avatar {
margin-bottom: 1px;
}
.arco-list-item-meta {
padding: 0;
}
}
:deep(.arco-list-item-meta-content) {
flex: 1;
border-bottom: 1px solid var(--color-neutral-3);
.arco-list-item-meta-description {
display: flex;
flex-flow: row;
justify-content: space-between;
.tip {
color: rgb(var(--gray-6));
}
.operation {
margin-right: 6px;
}
}
}
</style>

View File

@@ -1,140 +0,0 @@
<template>
<a-card :bordered="false">
<a-space :size="54">
<a-upload
:custom-request="customRequest"
list-type="picture-card"
:file-list="fileList"
:show-upload-button="true"
:show-file-list="false"
@change="uploadChange"
>
<template #upload-button>
<a-avatar :size="100" class="info-avatar">
<template #trigger-icon>
<icon-camera />
</template>
<img v-if="fileList.length" :src="fileList[0].url" />
</a-avatar>
</template>
</a-upload>
<a-descriptions
:data="renderData"
:column="2"
align="right"
layout="inline-horizontal"
:label-style="{
width: '140px',
fontWeight: 'normal',
color: 'rgb(var(--gray-8))',
}"
:value-style="{
width: '200px',
paddingLeft: '8px',
textAlign: 'left',
}"
>
<template #label="{ label }">{{ $t(label) }} :</template>
<template #value="{ value, data }">
<a-tag v-if="data.label === 'userSetting.label.certification'" color="green" size="small">已认证</a-tag>
<span v-else>{{ value }}</span>
</template>
</a-descriptions>
</a-space>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { FileItem, RequestOption } from '@arco-design/web-vue/es/upload/interfaces'
import { useUserStore } from '@/store'
import { userUploadApi } from '@/api/user-center'
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface'
const userStore = useUserStore()
const file = {
uid: '-2',
name: 'avatar.png',
url: userStore.avatar,
}
const renderData = [
{
label: 'userSetting.label.name',
value: userStore.name,
},
{
label: 'userSetting.label.certification',
value: userStore.certification,
},
{
label: 'userSetting.label.accountId',
value: userStore.accountId,
},
{
label: 'userSetting.label.phone',
value: userStore.phone,
},
{
label: 'userSetting.label.registrationDate',
value: userStore.registrationDate,
},
] as DescData[]
const fileList = ref<FileItem[]>([file])
const uploadChange = (fileItemList: FileItem[], fileItem: FileItem) => {
fileList.value = [fileItem]
}
const customRequest = (options: RequestOption) => {
// docs: https://axios-http.com/docs/cancellation
const controller = new AbortController()
;(async function requestWrap() {
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
onProgress(20)
const formData = new FormData()
formData.append(name as string, fileItem.file as Blob)
const onUploadProgress = (event: ProgressEvent) => {
let percent
if (event.total > 0) {
percent = (event.loaded / event.total) * 100
}
onProgress(parseInt(String(percent), 10), event)
}
try {
// https://github.com/axios/axios/issues/1630
// https://github.com/nuysoft/Mock/issues/127
const res = await userUploadApi(formData, {
controller,
onUploadProgress,
})
onSuccess(res)
} catch (error) {
onError(error)
}
})()
return {
abort() {
controller.abort()
},
}
}
</script>
<style scoped lang="less">
.arco-card {
padding: 14px 0 4px 4px;
border-radius: 4px;
}
:deep(.arco-avatar-trigger-icon-button) {
width: 32px;
height: 32px;
line-height: 32px;
background-color: #e8f3ff;
.arco-icon-camera {
margin-top: 8px;
color: rgb(var(--arcoblue-6));
font-size: 14px;
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.user', 'menu.user.setting']" />
<a-row style="margin-bottom: 16px">
<a-col :span="24">
<UserPanel />
</a-col>
</a-row>
<a-row class="wrapper">
<a-col :span="24">
<a-tabs default-active-key="1" type="rounded">
<a-tab-pane key="1" :title="$t('userSetting.tab.basicInformation')">
<BasicInformation />
</a-tab-pane>
<a-tab-pane key="2" :title="$t('userSetting.tab.securitySettings')">
<SecuritySettings />
</a-tab-pane>
<a-tab-pane key="3" :title="$t('userSetting.tab.certification')">
<Certification />
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import UserPanel from './components/user-panel.vue'
import BasicInformation from './components/basic-information.vue'
import SecuritySettings from './components/security-settings.vue'
import Certification from './components/certification.vue'
</script>
<script lang="ts">
export default {
name: 'Setting',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.wrapper {
padding: 20px 0 0 20px;
min-height: 580px;
background-color: var(--color-bg-2);
border-radius: 4px;
}
:deep(.section-title) {
margin-top: 0;
margin-bottom: 16px;
font-size: 14px;
}
</style>

View File

@@ -1,80 +0,0 @@
export default {
'menu.user.setting': 'User Setting',
'userSetting.menu.title.info': 'Personal Information',
'userSetting.menu.title.account': 'Account Setting',
'userSetting.menu.title.password': 'Password',
'userSetting.menu.title.message': 'Message Notification',
'userSetting.menu.title.result': 'Result',
'userSetting.menu.title.data': 'Export Data',
'userSetting.saveSuccess': 'Save Success',
'userSetting.title.basicInfo': 'Basic Information',
'userSetting.title.socialInfo': 'Social Information',
'userSetting.label.avatar': 'Avatar',
'userSetting.label.name': 'User Name',
'userSetting.label.location': 'Office Location',
'userSetting.label.introduction': 'Introduction',
'userSetting.label.personalWebsite': 'Website',
'userSetting.save': 'Save',
'userSetting.cancel': 'Cancel',
'userSetting.reset': 'Reset',
// new
'userSetting.label.certification': 'Certification',
'userSetting.label.phone': 'Phone',
'userSetting.label.accountId': 'Account Id',
'userSetting.label.registrationDate': 'Registration Date',
'userSetting.tab.basicInformation': 'Basic Information',
'userSetting.tab.securitySettings': 'Security Settings',
'userSetting.tab.certification': 'Certification',
'userSetting.basicInfo.form.label.email': 'Email',
'userSetting.basicInfo.placeholder.email': `Please enter your email address, such as xxx{'@'}bytedance.com`,
'userSetting.form.error.email.required': 'Please enter email address',
'userSetting.basicInfo.form.label.nickname': 'Nickname',
'userSetting.basicInfo.placeholder.nickname': 'Please enter nickname',
'userSetting.form.error.nickname.required': 'Please enter nickname',
'userSetting.basicInfo.form.label.countryRegion': 'Country/region',
'userSetting.basicInfo.placeholder.countryRegion': 'Please select country/region',
'userSetting.form.error.countryRegion.required': 'Please select country/region',
'userSetting.basicInfo.form.label.area': 'Area',
'userSetting.basicInfo.placeholder.area': 'Please select area',
'userSetting.form.error.area.required': 'Please Select a area',
'userSetting.basicInfo.form.label.address': 'Address',
'userSetting.basicInfo.placeholder.address': 'Please enter address',
'userSetting.basicInfo.form.label.profile': 'Personal profile',
'userSetting.basicInfo.placeholder.profile': 'Please enter your profile, no more than 200 words',
'userSetting.form.error.profile.maxLength': 'No more than 200 words',
'userSetting.SecuritySettings.form.label.password': 'Login Password',
'userSetting.SecuritySettings.placeholder.password':
'Has been set. The password must contain at least six letters, digits, and special characters except Spaces. The password must contain both uppercase and lowercase letters.',
'userSetting.SecuritySettings.form.label.securityQuestion': 'Security Question',
'userSetting.SecuritySettings.placeholder.securityQuestion':
'You have not set the password protection question. The password protection question can effectively protect the account security.',
'userSetting.SecuritySettings.form.label.phone': 'Phone',
// 'userSetting.SecuritySettings.placeholder.phone': '已绑定150******50',
'userSetting.SecuritySettings.form.label.email': 'Email',
'userSetting.SecuritySettings.placeholder.email':
'You have not set a mailbox yet. The mailbox binding can be used to retrieve passwords and receive notifications.',
'userSetting.SecuritySettings.button.settings': 'Settings',
'userSetting.SecuritySettings.button.update': 'Update',
'userSetting.certification.title.enterprise': 'Enterprise Real Name Authentication',
'userSetting.certification.extra.enterprise': 'Modifying an Authentication Body',
'userSetting.certification.label.accountType': 'Account Type',
'userSetting.certification.label.status': 'status',
'userSetting.certification.label.time': 'time',
'userSetting.certification.label.legalPerson': 'Legal Person Name',
'userSetting.certification.label.certificateType': 'Types of legal person documents',
'userSetting.certification.label.authenticationNumber': 'Legal person certification number',
'userSetting.certification.label.enterpriseName': 'Enterprise Name',
'userSetting.certification.label.enterpriseCertificateType': 'Types of corporate certificates',
'userSetting.certification.label.organizationCode': 'Organization Code',
'userSetting.certification.title.record': 'Certification Records',
'userSetting.certification.columns.certificationType': 'Certification Type',
'userSetting.certification.cell.certificationType': 'Enterprise certificate Certification',
'userSetting.certification.columns.certificationContent': 'Certification Content',
'userSetting.certification.columns.status': 'Status',
'userSetting.certification.cell.pass': 'Pass',
'userSetting.certification.cell.auditing': 'Auditing',
'userSetting.certification.columns.time': 'Time',
'userSetting.certification.columns.operation': 'Operation',
'userSetting.certification.button.check': 'Check',
'userSetting.certification.button.withdraw': 'Withdraw',
}

View File

@@ -1,78 +0,0 @@
export default {
'menu.user.setting': '用户设置',
'userSetting.menu.title.info': '个人信息',
'userSetting.menu.title.account': '账号设置',
'userSetting.menu.title.password': '密码',
'userSetting.menu.title.message': '消息通知',
'userSetting.menu.title.result': '结果页',
'userSetting.menu.title.data': '导出数据',
'userSetting.saveSuccess': '保存成功',
'userSetting.title.basicInfo': '基本信息',
'userSetting.title.socialInfo': '社交信息',
'userSetting.label.avatar': '头像',
'userSetting.label.name': '用户名',
'userSetting.label.location': '办公地点',
'userSetting.label.introduction': '个人简介',
'userSetting.label.personalWebsite': '个人网站',
'userSetting.save': '保存',
'userSetting.cancel': '取消',
'userSetting.reset': '重置',
// new
'userSetting.label.certification': '实名认证',
'userSetting.label.phone': '手机号码',
'userSetting.label.accountId': '账号ID',
'userSetting.label.registrationDate': '注册时间',
'userSetting.tab.basicInformation': '基础信息',
'userSetting.tab.securitySettings': '安全设置',
'userSetting.tab.certification': '实名认证',
'userSetting.basicInfo.form.label.email': '邮箱',
'userSetting.basicInfo.placeholder.email': `请输入邮箱地址如xxx{'@'}bytedance.com`,
'userSetting.form.error.email.required': '请输入邮箱',
'userSetting.basicInfo.form.label.nickname': '昵称',
'userSetting.basicInfo.placeholder.nickname': '请输入您的昵称',
'userSetting.form.error.nickname.required': '请输入昵称',
'userSetting.basicInfo.form.label.countryRegion': '国家/地区',
'userSetting.basicInfo.placeholder.countryRegion': '请选择',
'userSetting.form.error.countryRegion.required': '请选择国家/地区',
'userSetting.basicInfo.form.label.area': '所在区域',
'userSetting.basicInfo.placeholder.area': '请选择',
'userSetting.form.error.area.required': '请选择所在区域',
'userSetting.basicInfo.form.label.address': '具体地址',
'userSetting.basicInfo.placeholder.address': '请输入您的地址',
'userSetting.basicInfo.form.label.profile': '个人简介',
'userSetting.basicInfo.placeholder.profile': '请输入您的个人简介最多不超过200字。',
'userSetting.form.error.profile.maxLength': '最多不超过200字',
'userSetting.SecuritySettings.form.label.password': '登录密码',
'userSetting.SecuritySettings.placeholder.password':
'已设置。密码至少6位字符支持数字、字母和除空格外的特殊字符且必须同时包含数字和大小写字母。',
'userSetting.SecuritySettings.form.label.securityQuestion': '密保问题',
'userSetting.SecuritySettings.placeholder.securityQuestion': '您暂未设置密保问题,密保问题可以有效的保护账号的安全。',
'userSetting.SecuritySettings.form.label.phone': '安全手机',
// 'userSetting.SecuritySettings.placeholder.phone': '已绑定150******50',
'userSetting.SecuritySettings.form.label.email': '安全邮箱',
'userSetting.SecuritySettings.placeholder.email': '您暂未设置邮箱,绑定邮箱可以用来找回密码、接收通知等。',
'userSetting.SecuritySettings.button.settings': '设置',
'userSetting.SecuritySettings.button.update': '修改',
'userSetting.certification.title.enterprise': '企业实名认证',
'userSetting.certification.extra.enterprise': '修改认证主体',
'userSetting.certification.label.accountType': '账号类型',
'userSetting.certification.label.status': '认证状态',
'userSetting.certification.label.time': '认证时间',
'userSetting.certification.label.legalPerson': '法人姓名',
'userSetting.certification.label.certificateType': '法人证件类型',
'userSetting.certification.label.authenticationNumber': '法人认证号码',
'userSetting.certification.label.enterpriseName': '企业名称',
'userSetting.certification.label.enterpriseCertificateType': '企业证件类型',
'userSetting.certification.label.organizationCode': '组织机构代码',
'userSetting.certification.title.record': '认证记录',
'userSetting.certification.columns.certificationType': '认证类型',
'userSetting.certification.cell.certificationType': '企业证件认证',
'userSetting.certification.columns.certificationContent': '认证内容',
'userSetting.certification.columns.status': '当前状态',
'userSetting.certification.cell.pass': '已通过',
'userSetting.certification.cell.auditing': '审核中',
'userSetting.certification.columns.time': '创建时间',
'userSetting.certification.columns.operation': '操作',
'userSetting.certification.button.check': '查看',
'userSetting.certification.button.withdraw': '撤回',
}

View File

@@ -1,42 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
setupMock({
setup() {
Mock.mock(new RegExp('/api/user/save-info'), () => {
return successResponseWrap('ok')
})
Mock.mock(new RegExp('/api/user/certification'), () => {
return successResponseWrap({
enterpriseInfo: {
accountType: '企业账号',
status: 0,
time: '2018-10-22 14:53:12',
legalPerson: '李**',
certificateType: '中国身份证',
authenticationNumber: '130************123',
enterpriseName: '低调有实力的企业',
enterpriseCertificateType: '企业营业执照',
organizationCode: '7*******9',
},
record: [
{
certificationType: 1,
certificationContent: '企业实名认证,法人姓名:李**',
status: 0,
time: '2021-02-28 10:30:50',
},
{
certificationType: 1,
certificationContent: '企业实名认证,法人姓名:李**',
status: 1,
time: '2020-05-13 08:00:00',
},
],
})
})
Mock.mock(new RegExp('/api/user/upload'), () => {
return successResponseWrap('ok')
})
},
})