优化仪表盘等

This commit is contained in:
李志强 2025-11-13 11:28:00 +08:00
parent db16ee70de
commit 117e2b3440
18 changed files with 863 additions and 442 deletions

View File

@ -1,5 +1,10 @@
import request from '@/utils/request'
/**
* 获取任务列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function listTasks(params) {
return request({
url: '/api/oa/tasks',
@ -8,6 +13,11 @@ export function listTasks(params) {
})
}
/**
* 获取任务详情
* @param {number|string} id 任务ID
* @returns {Promise}
*/
export function getTask(id) {
return request({
url: `/api/oa/tasks/${id}`,
@ -15,6 +25,11 @@ export function getTask(id) {
})
}
/**
* 创建任务
* @param {Object} data 任务数据
* @returns {Promise}
*/
export function createTask(data) {
return request({
url: '/api/oa/tasks',
@ -23,6 +38,12 @@ export function createTask(data) {
})
}
/**
* 更新任务
* @param {number|string} id 任务ID
* @param {Object} data 更新的数据
* @returns {Promise}
*/
export function updateTask(id, data) {
return request({
url: `/api/oa/tasks/${id}`,
@ -31,9 +52,30 @@ export function updateTask(id, data) {
})
}
/**
* 删除任务
* @param {number|string} id 任务ID
* @returns {Promise}
*/
export function deleteTask(id) {
return request({
url: `/api/oa/tasks/${id}`,
method: 'delete'
})
}
/**
* 获取待办任务列表
* @param {Object} params
* @param {number} params.tenantId 租户ID
* @param {number} params.id 用户ID
* @param {number} [params.limit] 限制条数
* @returns {Promise}
*/
export function getTodoTasks(params = {}) {
return request({
url: '/api/oa/tasks/todo',
method: 'get',
params,
});
}

View File

@ -5,36 +5,22 @@
<i class="fa fa-bars"></i>
</el-button>
<el-breadcrumb separator="/" class="bread">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item
v-for="(item, index) in breadcrumbs"
:key="index"
:to="(item.label === '首页' || index === breadcrumbs.length - 1) ? { path: item.path } : null"
>
<el-breadcrumb-item :to="{ path: '/' }" style="position: relative !important; top: 1px !important;">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index"
:to="(item.label === '首页' || index === breadcrumbs.length - 1) ? { path: item.path } : null" style="position: relative !important; top: 1px !important;">
{{ item.label }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="r-content">
<!-- 更新缓存按钮 -->
<el-button
circle
:icon="Refresh"
@click="refreshCache"
class="refresh-cache-btn"
:loading="cacheLoading"
title="更新菜单缓存"
/>
<el-button circle :icon="Refresh" @click="refreshCache" class="refresh-cache-btn" :loading="cacheLoading"
title="更新菜单缓存" />
<!-- 主题切换按钮 -->
<el-button
circle
:icon="themeIcon"
@click="toggleTheme"
class="theme-toggle-btn"
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
/>
<el-button circle :icon="themeIcon" @click="toggleTheme" class="theme-toggle-btn"
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
<el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link" style="cursor: pointer;">
<img :src="getImageUrl('user')" class="user" />
@ -43,11 +29,15 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<el-icon>
<User />
</el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
<el-icon>
<SwitchButton />
</el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
@ -56,13 +46,14 @@
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAllDataStore, useMenuStore } from "@/stores";
import { useAuthStore } from "@/stores/auth";
import { User, SwitchButton, Sunny, Moon, Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { ElMessage } from 'element-plus';
const router = useRouter();
const route = useRoute();
@ -95,19 +86,19 @@ async function refreshCache() {
cacheLoading.value = true;
try {
await menuStore.refreshMenus();
//
const { loadAndAddDynamicRoutes, resetDynamicRoutes } = await import('@/router/index');
//
resetDynamicRoutes();
await loadAndAddDynamicRoutes();
// Vue Router
await new Promise(resolve => setTimeout(resolve, 200));
// CommonAside
window.dispatchEvent(new CustomEvent('menu-cache-refreshed'));
ElMessage.success('更新成功');
} catch (error) {
console.error('Failed to refresh cache', error);
@ -117,14 +108,14 @@ async function refreshCache() {
}
}
onMounted(loadMenu);
onMounted(loadMenu);
//
const breadcrumbs = computed(() => {
let chain: Breadcrumb[] = [];
let currentPath = route.path || '/';
if (currentPath === '/' || currentPath === '') {
return [{ label: '仪表盘', path: '/' }];
return [{ label: '仪表盘', path: '/' }];
}
let current = menuList.value.find(m => m.path === currentPath);
if (!current) {
@ -141,8 +132,8 @@ const breadcrumbs = computed(() => {
parentId = parent.parentId;
} else break;
}
chain = chain.reverse();
return chain;
chain = chain.reverse();
return chain;
});
const store = useAllDataStore();
@ -156,17 +147,17 @@ const getImageUrl = (user) => {
const displayName = computed(() => {
const user = authStore.user;
if (!user) return '';
// name
if (user.type === 'employee' && user.name) {
return user.name;
}
// nickname
if (user.nickname) {
return user.nickname;
}
// username
return user.username || '';
});
@ -192,7 +183,7 @@ const handleCommand = async (command) => {
sessionStorage.removeItem('tabs_list');
//
menuStore.resetMenus();
// menu_cache_
const menuCacheKeys: string[] = [];
// localStorage
@ -206,7 +197,7 @@ const handleCommand = async (command) => {
menuCacheKeys.forEach(key => {
localStorage.removeItem(key);
});
// sessionStorage
const sessionMenuCacheKeys: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
@ -219,12 +210,12 @@ const handleCommand = async (command) => {
sessionMenuCacheKeys.forEach(key => {
sessionStorage.removeItem(key);
});
// tabs store
const { useTabsStore } = await import('@/stores');
const tabsStore = useTabsStore();
tabsStore.resetTabs();
router.push('/login');
}
};
@ -276,7 +267,7 @@ let handleChange: ((e: MediaQueryListEvent) => void) | null = null;
onMounted(() => {
initTheme();
//
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
handleChange = (e: MediaQueryListEvent) => {
@ -310,7 +301,7 @@ onUnmounted(() => {
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
// 使 #062da3
html:not(.dark) & {
background-color: #062da3;
@ -333,7 +324,7 @@ onUnmounted(() => {
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
color: var(--el-text-color-primary);
&:hover {
background-color: var(--el-fill-color);
border-color: var(--el-border-color-dark);
@ -348,7 +339,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 16px;
.refresh-cache-btn,
.theme-toggle-btn {
// 使 Element Plus
@ -356,14 +347,14 @@ onUnmounted(() => {
border-color: var(--el-border-color);
color: var(--el-text-color-primary);
margin-left: 0 !important;
&:hover {
background-color: var(--el-fill-color);
border-color: var(--el-border-color-dark);
color: var(--el-color-primary);
}
}
.user {
width: 40px;
height: 40px;
@ -371,18 +362,18 @@ onUnmounted(() => {
// 使 Element Plus
border: 2px solid var(--el-border-color);
transition: border-color 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
}
}
.el-dropdown-link {
display: flex;
align-items: center;
cursor: pointer;
gap: 12px;
.user-name {
font-size: 14px;
color: var(--el-text-color-primary);
@ -391,7 +382,7 @@ onUnmounted(() => {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
// 使
html:not(.dark) & {
color: #ffffff;
@ -407,27 +398,27 @@ onUnmounted(() => {
background-color: var(--bg-color-overlay) !important;
border-color: var(--border-color) !important;
padding: 4px 0;
.el-dropdown-menu__item {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color-primary) !important;
transition: background-color 0.2s ease, color 0.2s ease;
span {
margin-left: 0;
}
.el-icon {
color: var(--text-color-primary) !important;
}
&:not(.is-disabled):hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.is-divided {
border-top-color: var(--border-color) !important;
}
@ -437,18 +428,23 @@ onUnmounted(() => {
}
:deep(.bread) {
// 使
.el-breadcrumb__inner {
color: #ffffff !important;
}
.el-breadcrumb__inner.is-link {
color: #ffffff !important;
cursor: pointer !important;
&:hover {
color: rgba(255, 255, 255, 0.8) !important;
}
}
}
:deep(.el-tabs__nav){
height: 36px !important;
}
</style>

View File

@ -9,6 +9,22 @@
<el-descriptions-item label="进度">{{ Number(task.progress||0) }}%</el-descriptions-item>
<el-descriptions-item label="截止时间" :span="2">{{ formatDateTime(task.plan_end_time) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ task.task_desc || '-' }}</el-descriptions-item>
<el-descriptions-item label="附件" :span="2">
<div v-if="attachments.length" class="attachments">
<div v-for="file in attachments" :key="file.id" class="attachment-item">
<el-image
:src="file.url"
:preview-src-list="previewList"
fit="cover"
:hide-on-click-modal="true"
:initial-index="file.index"
style="width: 80px; height: 80px; border-radius: 6px; overflow: hidden;"
/>
<a :href="file.url" target="_blank" rel="noopener" class="attachment-link">{{ file.name }}</a>
</div>
</div>
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="visible=false">关闭</el-button>
@ -19,6 +35,7 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useDictStore } from '@/stores/dict'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, task: any }>()
const emit = defineEmits(['update:modelValue'])
@ -39,7 +56,51 @@ const formatDateTime = (v: any) => {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const attachments = ref<Array<{ id: string|number, url: string, name: string, index: number }>>([])
const previewList = ref<string[]>([])
// 使 Authorization
const buildRelativePreview = (id: string|number) => `/api/files/public-preview/${id}`
const resolveFileUrl = (path: string): string => {
if (!path) return ''
if (/^https?:\/\//i.test(path)) return path
const base = (request as any)?.defaults?.baseURL || ''
try {
const origin = new URL(base, window.location.origin).origin
return origin.replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '')
} catch {
return path
}
}
watch(() => props.task, (t) => {
const ids: Array<string> = Array.isArray(t?.attachment_ids)
? (t?.attachment_ids as any[]).map((x: any) => String(x))
: (typeof t?.attachment_ids === 'string' && t.attachment_ids
? String(t.attachment_ids).split(',').map((x) => x.trim()).filter(Boolean)
: [])
previewList.value = ids.map(id => resolveFileUrl(buildRelativePreview(id)))
attachments.value = ids.map((id, idx) => ({ id, url: resolveFileUrl(buildRelativePreview(id)), name: id, index: idx }))
}, { immediate: true })
</script>
<style scoped>
.attachments {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
display: flex;
flex-direction: column;
align-items: center;
width: 100px;
}
.attachment-link {
margin-top: 6px;
font-size: 12px;
color: var(--el-color-primary);
text-decoration: none;
word-break: break-all;
}
</style>

View File

@ -46,6 +46,25 @@
<el-form-item label="描述" prop="task_desc">
<el-input v-model="form.task_desc" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="附件">
<el-upload
:limit="5"
list-type="picture-card"
:file-list="uploadFileList"
:before-upload="beforeUpload"
:http-request="handleCustomUpload"
:on-success="handleUploadSuccess"
:on-remove="handleUploadRemove"
:on-preview="handleUploadPreview"
accept="image/*"
multiple
>
<el-icon><Plus /></el-icon>
</el-upload>
<el-dialog v-model="previewVisible" width="720px">
<img :src="previewImageUrl" style="width: 100%" />
</el-dialog>
</el-form-item>
</el-form>
<EmployeeSelectDialog
v-model:visible="employeeDialogVisible"
@ -72,6 +91,9 @@ import { useDictStore } from '@/stores/dict'
import EmployeeSelectDialog from './EmployeeSelectDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenantEmployees } from '@/api/employee'
import { Plus } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/file'
import request from '@/utils/request'
const props = defineProps<{
modelValue: boolean,
@ -105,6 +127,22 @@ const form = reactive<any>({
// id team_employee_ids
const teamEmployeeIds = ref<Array<number | string>>([])
const teamEmployeeList = ref<Array<{id: number|string, name: string}>>([])
//
const attachmentIds = ref<Array<number|string>>([])
const uploadFileList = ref<any[]>([])
const previewVisible = ref(false)
const previewImageUrl = ref('')
const resolveFileUrl = (path: string): string => {
if (!path) return ''
if (/^https?:\/\//i.test(path)) return path
const base = (request as any)?.defaults?.baseURL || ''
try {
const origin = new URL(base, window.location.origin).origin
return origin.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
} catch {
return path
}
}
const authStore = useAuthStore()
const loadTeamEmployeeNames = async (ids: Array<number | string>) => {
if (!ids || ids.length === 0) {
@ -183,6 +221,16 @@ watch(() => props.task, (t) => {
} else {
loadTeamEmployeeNames(teamEmployeeIds.value)
}
//
if (Array.isArray(t?.attachment_ids)) {
attachmentIds.value = [...t.attachment_ids]
} else if (typeof t?.attachment_ids === 'string' && t.attachment_ids) {
attachmentIds.value = t.attachment_ids.split(',').map((x: string) => x.trim()).filter(Boolean)
} else {
attachmentIds.value = []
}
// URL
uploadFileList.value = uploadFileList.value
}, { immediate: true })
const rules = reactive<FormRules<any>>({
@ -239,6 +287,14 @@ const onSubmit = async () => {
const n = Number(x)
return isNaN(n) ? x : n
})
// ID attachment_ids id
if (attachmentIds.value.length) {
const ids = (attachmentIds.value || []).map((x: any) => {
const n = Number(x)
return isNaN(n) ? String(x) : String(n)
})
payload.attachment_ids = ids.join(',')
}
if (props.isEdit && form.id) {
res = await updateTask(form.id, payload)
@ -264,6 +320,63 @@ const onSubmit = async () => {
const onClosed = () => {
// reset if needed
}
//
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('仅支持图片类型')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
//
const handleCustomUpload = async (options: any) => {
const { file, onError, onSuccess } = options
try {
const formData = new FormData()
formData.append('file', file as File)
const res = await uploadFile(formData, { category: '图片' })
onSuccess && onSuccess(res)
} catch (err) {
ElMessage.error('上传失败')
onError && onError(err)
}
}
// id file_url
const handleUploadSuccess = (response: any, file: any, fileList: any[]) => {
const id = response?.data?.id ?? response?.id
const rawUrl = response?.data?.file_url || response?.data?.url || response?.url || ''
if (id != null) {
attachmentIds.value.push(id)
}
const abs = rawUrl ? resolveFileUrl(rawUrl) : (file?.url || '')
// url使 blob: URL
uploadFileList.value = fileList.map((f: any) => {
if (f.uid === file.uid) {
return { ...f, url: abs }
}
return { ...f, url: f.url }
})
if (abs) ElMessage.success('上传成功')
}
const handleUploadRemove = (file: any, fileList: any[]) => {
const id = file?.response?.data?.id ?? file?.name
attachmentIds.value = attachmentIds.value.filter(x => String(x) !== String(id))
uploadFileList.value = fileList
}
const handleUploadPreview = (file: any) => {
const url = file?.url || ''
previewImageUrl.value = url.startsWith('blob:') ? url : resolveFileUrl(url)
previewVisible.value = !!previewImageUrl.value
}
</script>
<style scoped>

View File

@ -46,7 +46,7 @@
<template #default="{ row }">
<el-button link size="small" @click="openDetail(row)">查看</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="openDelete(row)">删除</el-button>
<el-button v-if="canDelete(row)" link type="danger" size="small" @click="openDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -87,6 +87,7 @@ import { listTasks, getTask, deleteTask } from '@/api/tasks'
import Edit from './components/edit.vue'
import Detail from './components/detail.vue'
import { useDictStore } from '@/stores/dict'
import { useAuthStore } from '@/stores/auth'
const loading = ref(false)
const total = ref(0)
@ -110,6 +111,8 @@ const currentTask = ref<any>(null)
const detailVisible = ref(false)
const deleteDialogVisible = ref(false) //
const authStore = useAuthStore()
const fetchList = async () => {
loading.value = true
try {
@ -179,6 +182,13 @@ const openDelete = (row: any) => {
.catch(() => {})
}
//
const canDelete = (row: any) => {
const uid = authStore?.user?.id
const cid = row?.creator_id ?? row?.creatorId
return uid != null && cid != null && String(uid) === String(cid)
}
const priorityTagType = (v: string) => {
if (v === 'urgent') return 'danger'
if (v === 'high') return 'warning'

View File

@ -356,6 +356,7 @@ import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { ElMessage } from "element-plus";
import { getKnowledgeList } from "@/api/knowledge";
import { getTodoTasks } from "@/api/tasks";
import {
FolderOpened,
StarFilled,
@ -605,78 +606,24 @@ const industryNews = ref([
const sortType = ref("time");
//
const tasks = ref([
{
id: 1,
title: "设计部李亦想的加班申请流程",
node: "直属领导审批",
applyTime: "2024-01-20 13:32",
system: "oa",
},
{
id: 2,
title: "财务部张申请的报销流程",
node: "财务审核",
applyTime: "2024-01-19 10:15",
system: "finance",
},
{
id: 3,
title: "人事部王申请的请假流程",
node: "人事审批",
applyTime: "2024-01-18 14:20",
system: "oa",
},
{
id: 4,
title: "技术部赵申请的出差申请",
node: "部门经理审批",
applyTime: "2024-01-17 09:30",
system: "oa",
},
{
id: 5,
title: "市场部钱申请的采购申请",
node: "采购部门审批",
applyTime: "2024-01-16 15:45",
system: "oa",
},
{
id: 6,
title: "销售部孙申请的合同审批",
node: "法务部门审批",
applyTime: "2024-01-15 11:20",
system: "oa",
},
{
id: 7,
title: "运营部周申请的预算申请",
node: "财务部门审批",
applyTime: "2024-01-14 16:10",
system: "finance",
},
{
id: 8,
title: "产品部吴申请的项目立项",
node: "总经理审批",
applyTime: "2024-01-13 08:50",
system: "oa",
},
{
id: 9,
title: "研发部郑申请的设备采购",
node: "采购部门审批",
applyTime: "2024-01-12 14:25",
system: "oa",
},
{
id: 10,
title: "行政部王申请的办公用品采购",
node: "行政主管审批",
applyTime: "2024-01-11 10:00",
system: "oa",
},
]);
const tasks = ref([]);
async function fetchTodoTasks() {
try {
const tenantId = authStore?.user?.tenant_id || 0;
const res = await getTodoTasks({ tenantId, limit: 10 });
const list = res?.data?.list || res?.data || [];
tasks.value = list.map((item) => ({
id: item.id,
title: item.task_name || item.title || "",
node: item.node || item.task_status || "",
applyTime: item.apply_time || item.created_time || item.createdAt || "",
system: item.system || "oa",
}));
} catch (e) {
console.error("获取待办任务失败", e);
}
}
const filteredTasks = computed(() => {
return tasks.value;
@ -885,6 +832,8 @@ onMounted(() => {
document.addEventListener("click", handleClickOutside);
//
fetchKnowledgeList();
//
fetchTodoTasks();
});
onUnmounted(() => {

View File

@ -93,16 +93,19 @@
</div>
<div class="list-content">
<div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }">
<el-checkbox v-model="task.completed" @change="handleTaskChange(task)" />
<!-- <el-checkbox v-model="task.completed" @change="handleTaskChange(task)" /> -->
<div class="task-info">
<div class="task-title">
{{ task.title }}
<el-tag :type="getPriorityType(task.priority)" size="small" effect="plain">
{{ task.priority }}
<el-tag :type="task.priorityTagType || getPriorityType(task.priority)" size="small" effect="plain" :class="'priority-' + task.priority">
{{ task.priorityLabel || task.priority }}
</el-tag>
</div>
<div class="task-meta">
<span class="task-date">{{ task.date }}</span>
<span class="task-date">
<el-icon><Clock /></el-icon>
{{ formatDate(task.date) }}
</span>
</div>
</div>
</div>
@ -164,8 +167,10 @@ import {
View,
} from "@element-plus/icons-vue";
import { getKnowledgeCount } from "@/api/knowledge";
import { getTodoTasks } from "@/api/tasks";
import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
import { useAuthStore } from "@/stores/auth";
import { useDictStore } from '@/stores/dict'
Chart.register(...registerables);
@ -179,6 +184,14 @@ const currentDate = computed(() => {
return `${month}${day}${weekday}`;
});
//
const formatDate = (date: string) => {
const d = new Date(date);
const month = d.getMonth() + 1;
const day = d.getDate();
return `${month}${day}`;
};
// 使 markRaw
const stats = ref([
{
@ -288,56 +301,48 @@ const fetchTenantStats = async () => {
const taskCurrentPage = ref(1);
const taskPageSize = ref(5);
const tasks = ref([
{
title: "完成Q2预算审核",
date: "2024-06-11",
priority: "High",
completed: false,
},
{
title: "发邮件通知团队",
date: "2024-06-10",
priority: "Medium",
completed: true,
},
{
title: "产品需求讨论会",
date: "2024-06-09",
priority: "Low",
completed: false,
},
{
title: "准备季度报告",
date: "2024-06-12",
priority: "High",
completed: false,
},
{
title: "更新项目文档",
date: "2024-06-13",
priority: "Medium",
completed: false,
},
{
title: "代码审查",
date: "2024-06-14",
priority: "Low",
completed: false,
},
{
title: "客户需求沟通",
date: "2024-06-15",
priority: "High",
completed: false,
},
{
title: "测试环境部署",
date: "2024-06-16",
priority: "Medium",
completed: false,
},
]);
const tasks = ref<any[]>([]);
const priorityDict = ref<any[]>([]);
async function fetchPriorityDict() {
try {
const dictStore = useDictStore();
priorityDict.value = await dictStore.getDictItems('task_priority');
} catch (e) {
// ignore
}
}
async function fetchTodoTasksDashboard() {
try {
const tenantId = authStore?.user?.tenant_id || 0;
const id = authStore?.user?.id || 0;
const res = await getTodoTasks({ tenantId, id, limit: 20 });
const list = res?.data?.list || res?.data || [];
//
tasks.value = list.map((item: any) => ({
title: item.task_name || item.title || "",
date: item.created_time || item.apply_time || item.updated_time || "",
priority: item.priority || "",
priorityLabel: (() => {
const dictItem = priorityDict.value.find((d: any) => String(d.dict_value) === String(item.priority));
return dictItem?.dict_label || (item.priority ? capitalize(item.priority) : 'Medium');
})(),
priorityTagType: (() => {
const dictItem = priorityDict.value.find((d: any) => String(d.dict_value) === String(item.priority));
return dictItem?.dict_tag_type || undefined;
})(),
completed: item.task_status === 'completed' || item.task_status === 'closed',
}));
} catch (e) {
console.warn('获取待办任务失败', e);
}
}
function capitalize(s: string) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
//
const paginatedTasks = computed(() => {
@ -445,130 +450,128 @@ onMounted(() => {
//
fetchActivityLogs();
//
fetchPriorityDict();
fetchTodoTasksDashboard();
// 线
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
if (!lineChartEl) {
console.error("Line chart element not found");
return;
if (lineChartEl) {
new Chart(lineChartEl, {
type: "line",
data: {
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
datasets: [
{
label: "收入(元)",
data: [16800, 19400, 23100, 24600, 27600, 31000, 35800],
fill: true,
borderColor: "#4f84ff",
backgroundColor: "rgba(79, 132, 255, 0.1)",
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: "#4f84ff",
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
titleFont: { size: 14 },
bodyFont: { size: 13 },
cornerRadius: 6,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
color: "var(--el-border-color-lighter)",
drawBorder: false,
},
},
x: {
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
display: false,
},
},
},
},
});
}
new Chart(lineChartEl, {
type: "line",
data: {
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
datasets: [
{
label: "收入(元)",
data: [16800, 19400, 23100, 24600, 27600, 31000, 35800],
fill: true,
borderColor: "#4f84ff",
backgroundColor: "rgba(79, 132, 255, 0.1)",
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: "#4f84ff",
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
titleFont: { size: 14 },
bodyFont: { size: 13 },
cornerRadius: 6,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
color: "var(--el-border-color-lighter)",
drawBorder: false,
},
},
x: {
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
display: false,
},
},
},
},
});
//
const barChartEl = document.getElementById("barChart") as HTMLCanvasElement | null;
if (!barChartEl) {
console.error("Bar chart element not found");
return;
if (barChartEl) {
new Chart(barChartEl, {
type: "bar",
data: {
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
datasets: [
{
label: "活跃用户",
data: [140, 162, 189, 176, 206, 238, 205],
backgroundColor: "rgba(79, 132, 255, 0.8)",
borderRadius: 6,
barPercentage: 0.6,
borderSkipped: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
titleFont: { size: 14 },
bodyFont: { size: 13 },
cornerRadius: 6,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
color: "var(--el-border-color-lighter)",
drawBorder: false,
},
},
x: {
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
display: false,
},
},
},
},
});
}
new Chart(barChartEl, {
type: "bar",
data: {
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
datasets: [
{
label: "活跃用户",
data: [140, 162, 189, 176, 206, 238, 205],
backgroundColor: "rgba(79, 132, 255, 0.8)",
borderRadius: 6,
barPercentage: 0.6,
borderSkipped: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
titleFont: { size: 14 },
bodyFont: { size: 13 },
cornerRadius: 6,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
color: "var(--el-border-color-lighter)",
drawBorder: false,
},
},
x: {
ticks: {
color: "var(--el-text-color-regular)",
font: { size: 12 },
},
grid: {
display: false,
},
},
},
},
});
});
</script>
@ -822,6 +825,7 @@ onMounted(() => {
&.done {
.task-info {
margin-left: 20px;
.task-title {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
@ -978,4 +982,22 @@ onMounted(() => {
grid-template-columns: 1fr;
}
}
.priority-0 {
color: #e6a23c;
background-color: #fdf6ec;
border-color: #f5dab1;
}
.priority-1 {
color: #67c23a;
background-color: rgb(240, 249, 235);
border-color: rgb(225, 243, 216);
}
.priority-2 {
color: #f56c6c;
background-color: #fef0f0;
border-color: #fbc4c4;
}
</style>

View File

@ -1,11 +1,5 @@
<template>
<el-dialog
title="租户详情"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-dialog title="租户详情" v-model="visible" width="600px" :close-on-click-modal="false" @close="handleClose">
<div class="tenant-detail" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
@ -14,9 +8,6 @@
<el-descriptions-item label="租户名称">
{{ tenantData.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ tenantData.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ tenantData.owner }}
</el-descriptions-item>
@ -27,12 +18,12 @@
{{ tenantData.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getAuditStatusType(tenantData.audit_status)">
<el-tag :type="getAuditStatusType(tenantData.audit_status)" :class="`${tenantData.audit_status}-color`">
{{ getAuditStatusText(tenantData.audit_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getTenantStatusType(tenantData.status)">
<el-tag :type="getTenantStatusType(tenantData.status)" :class="`color-${tenantData.status}`">
{{ getTenantStatusText(tenantData.status) }}
</el-tag>
</el-descriptions-item>
@ -40,19 +31,28 @@
<span>{{ formatCapacity(tenantData.capacity) }}</span>
</el-descriptions-item>
<el-descriptions-item label="已使用">
<span>{{ formatCapacity(tenantData.capacity_used || 0) }}</span>
<el-progress
:percentage="getCapacityPercentage(tenantData.capacity, tenantData.capacity_used)"
:color="getCapacityColor(tenantData.capacity, tenantData.capacity_used)"
:stroke-width="6"
style="margin-top: 8px; width: 200px;"
/>
<div style="display: flex;flex-direction: column;">
<span>{{ formatCapacity(tenantData.capacity_used || 0) }}</span>
<el-progress :percentage="getCapacityPercentage(tenantData.capacity, tenantData.capacity_used)"
:color="getCapacityColor(tenantData.capacity, tenantData.capacity_used)" :stroke-width="6"
style="margin-top: 8px;" />
</div>
</el-descriptions-item>
<el-descriptions-item label="附件" :span="2">
<template v-if="tenantData.attachment_url">
<el-image :src="resolveFileUrl(tenantData.attachment_url)"
:preview-src-list="[resolveFileUrl(tenantData.attachment_url)]" fit="cover"
style="width: 160px; height: 120px; border-radius: 4px;" />
</template>
<template v-else>
</template>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ tenantData.created_at }}
{{ formatTime(tenantData.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ tenantData.updated_at || '未更新' }}
{{ formatTime(tenantData.updated_at) || '未更新' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ tenantData.remark || '无' }}
@ -69,7 +69,9 @@
import { ref, watch, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { getTenantDetail } from '@/api/tenant';
import request from '@/utils/request';
import { useDictStore } from '@/stores/dict';
import dayjs from 'dayjs';
interface Props {
modelValue: boolean;
@ -125,7 +127,12 @@ function getAuditStatusType(status: string): 'warning' | 'success' | 'danger' |
if (dictItem) {
return dictItem.dict_tag_type || 'info';
}
//
function formatTime(time: string | number | Date): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
}
//
const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> = {
pending: 'warning',
@ -143,7 +150,7 @@ function getAuditStatusText(status: string) {
if (dictItem) {
return dictItem.dict_label || String(status);
}
//
const statusMap: Record<string, string> = {
pending: '待审核',
@ -162,7 +169,7 @@ function getTenantStatusType(status: string | number): 'success' | 'info' | 'war
if (dictItem) {
return dictItem.dict_tag_type || 'info';
}
//
return String(status) === '1' ? 'success' : 'info';
}
@ -175,7 +182,7 @@ function getTenantStatusText(status: string | number): string {
if (dictItem) {
return dictItem.dict_label || String(status);
}
//
return String(status) === '1' ? '启用' : '禁用';
}
@ -213,10 +220,22 @@ function getCapacityColor(capacity: number | undefined, used: number | undefined
return '#67c23a'; // 绿
}
function resolveFileUrl(path: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
const base = (request as any)?.defaults?.baseURL || '';
if (!base) return path;
return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
}
function formatTime(time: string | number | Date): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
}
//
async function fetchDetail() {
if (!props.tenantId) return;
loading.value = true;
try {
const res = await getTenantDetail(props.tenantId);
@ -277,6 +296,11 @@ watch(
margin-top: 0;
}
:deep(.el-descriptions__cell) {
width: 180px;
text-align: right;
}
:deep(.el-descriptions-item__label) {
font-weight: 600;
color: var(--el-text-color-regular);
@ -286,5 +310,23 @@ watch(
color: var(--el-text-color-primary);
}
}
</style>
.pending-color {
color: #e6a23c;
background-color: #fdf6ec;
border-color: #f5dab1;
}
.approved-color,
.color-1 {
color: #67c23a;
background-color: rgb(240, 249, 235);
border-color: rgb(225, 243, 216);
}
.rejected-color {
color: #f56c6c;
background-color: #fef0f0;
border-color: #fbc4c4;
}
</style>

View File

@ -15,9 +15,6 @@
<el-form-item label="租户名称" prop="name">
<el-input v-model="tenantForm.name" placeholder="请输入租户名称" />
</el-form-item>
<el-form-item label="租户编码" prop="code">
<el-input v-model="tenantForm.code" placeholder="请输入租户编码" />
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="tenantForm.owner" placeholder="请输入负责人" />
</el-form-item>
@ -56,6 +53,24 @@
placeholder="请输入备注"
/>
</el-form-item>
<el-form-item label="附件">
<el-upload
:limit="1"
list-type="picture-card"
:file-list="uploadFileList"
:before-upload="beforeUpload"
:http-request="handleCustomUpload"
:on-success="handleUploadSuccess"
:on-remove="handleRemove"
:on-preview="handlePreview"
accept="image/*"
>
<el-icon><Plus /></el-icon>
</el-upload>
<el-dialog v-model="previewVisible" width="500px">
<img :src="previewUrl" style="width: 100%" />
</el-dialog>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
@ -73,8 +88,14 @@ import {
ElMessageBox,
type FormInstance,
type FormRules,
type UploadFile,
type UploadUserFile,
type UploadRequestOptions,
} from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { createTenant, updateTenant } from '@/api/tenant';
import { uploadFile } from '@/api/file';
import request from '@/utils/request';
import { useDictStore } from '@/stores/dict';
interface Props {
@ -99,6 +120,9 @@ const visible = computed({
const submitting = ref(false);
const tenantFormRef = ref<FormInstance>();
const uploadFileList = ref<UploadUserFile[]>([]);
const previewVisible = ref(false);
const previewUrl = ref('');
//
const dictStore = useDictStore();
@ -122,18 +146,17 @@ const isEditing = computed(() => {
const tenantForm = reactive({
id: null as number | null,
name: '',
code: '',
owner: '',
phone: '',
email: '',
status: '1', // dict_value
status: '1', // dict_value
capacity: 0,
remark: '',
attachment_url: '' as string,
});
const formRules: FormRules = {
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入租户编码', trigger: 'blur' }],
owner: [{ required: true, message: '请输入负责人', trigger: 'blur' }],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
@ -146,17 +169,30 @@ const formRules: FormRules = {
],
};
function resolveFileUrl(path: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
const base = (request as any)?.defaults?.baseURL || '';
try {
const origin = new URL(base, window.location.origin).origin;
return origin.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
} catch {
return path;
}
}
//
function resetForm() {
tenantForm.id = null;
tenantForm.name = '';
tenantForm.code = '';
tenantForm.owner = '';
tenantForm.phone = '';
tenantForm.email = '';
tenantForm.status = '1'; // dict_value
tenantForm.status = '1'; // dict_value
tenantForm.capacity = 0;
tenantForm.remark = '';
tenantForm.attachment_url = '';
uploadFileList.value = [];
tenantFormRef.value?.resetFields();
}
@ -165,7 +201,6 @@ function initFormData() {
if (props.tenant && props.tenant.id) {
tenantForm.id = props.tenant.id;
tenantForm.name = props.tenant.name || '';
tenantForm.code = props.tenant.code || '';
tenantForm.owner = props.tenant.owner || '';
tenantForm.phone = props.tenant.phone || '';
tenantForm.email = props.tenant.email || '';
@ -174,6 +209,10 @@ function initFormData() {
// capacity MB使
tenantForm.capacity = props.tenant.capacity != null ? Number(props.tenant.capacity) : 0;
tenantForm.remark = props.tenant.remark || '';
tenantForm.attachment_url = props.tenant.attachment_url || '';
uploadFileList.value = tenantForm.attachment_url
? [{ name: '附件', url: resolveFileUrl(tenantForm.attachment_url) }]
: [];
} else {
resetForm();
}
@ -188,14 +227,14 @@ async function handleSubmit() {
try {
const tenantData = {
name: tenantForm.name,
code: tenantForm.code,
owner: tenantForm.owner,
phone: tenantForm.phone || '',
email: tenantForm.email || '',
status: Number(tenantForm.status), //
status: Number(tenantForm.status), //
// capacity 使 MB
capacity: tenantForm.capacity || 0,
remark: tenantForm.remark || '',
attachment_url: tenantForm.attachment_url || '',
};
let res;
@ -253,6 +292,55 @@ watch(
},
{ deep: true }
);
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith('image/');
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isImage) {
ElMessage.error('仅支持图片类型');
return false;
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB');
return false;
}
return true;
};
const handleUploadSuccess = (response: any, file: UploadFile) => {
const raw = response?.data?.file_url || response?.data?.url || response?.url || '';
if (raw) {
tenantForm.attachment_url = raw;
const abs = resolveFileUrl(raw);
uploadFileList.value = [{ name: file.name, url: abs }];
ElMessage.success('上传成功');
} else {
ElMessage.error('上传成功但未返回URL');
}
};
const handleRemove = () => {
tenantForm.attachment_url = '';
uploadFileList.value = [];
};
const handlePreview = (file: UploadFile) => {
previewUrl.value = (file.url as string) || tenantForm.attachment_url || '';
if (previewUrl.value) previewVisible.value = true;
};
const handleCustomUpload = async (options: UploadRequestOptions) => {
const { file, onError, onSuccess } = options as any;
try {
const formData = new FormData();
formData.append('file', file as File);
const res = await uploadFile(formData, { category: '图片' });
onSuccess && onSuccess(res);
} catch (err) {
ElMessage.error('上传失败');
onError && onError(err);
}
};
</script>
<style lang="less" scoped>
@ -262,4 +350,3 @@ watch(
margin-top: 4px;
}
</style>

View File

@ -4,11 +4,15 @@
<h2 class="page-title">租户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
<el-icon>
<Plus />
</el-icon>
添加租户
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon><Refresh /></el-icon>
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
@ -32,17 +36,28 @@
<!-- 租户列表 -->
<div v-else>
<el-card shadow="never">
<el-table
:data="tenants"
stripe
style="width: 100%"
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table :data="tenants" stripe style="width: 100%" v-loading="loading" element-loading-text="正在加载...">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="租户名称" min-width="120" />
<el-table-column prop="code" label="租户编码" width="120" />
<el-table-column prop="name" label="租户名称" min-width="240" />
<el-table-column prop="owner" label="负责人" width="100" />
<el-table-column label="审核状态" width="100">
<template #default="{ row }">
<el-tag v-if="reviewStatusDict.length" :type="getAuditStatusType(row.audit_status)"
:class="`${row.audit_status}-color`">
{{ getAuditStatusText(row.audit_status) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag v-if="tenantStatusDict.length" :type="getTenantStatusType(row.status)"
:class="`color-${row.status}`">
{{ getTenantStatusText(row.status) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="存储容量" min-width="240">
<template #default="{ row }">
<div class="capacity-cell">
@ -51,60 +66,41 @@
<span class="capacity-divider">/</span>
<span>{{ formatCapacity(row.capacity ?? 0) }}</span>
</div>
<el-progress
:percentage="getCapacityPercentage(row.capacity, row.capacity_used)"
:color="getCapacityColor(row.capacity, row.capacity_used)"
:stroke-width="4"
style="margin-top: 4px;"
/>
<el-progress :percentage="getCapacityPercentage(row.capacity, row.capacity_used)"
:color="getCapacityColor(row.capacity, row.capacity_used)" :stroke-width="4"
style="margin-top: 4px;" />
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="150" />
<el-table-column label="审核状态" width="100">
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<el-tag :type="getAuditStatusType(row.audit_status)">
{{ getAuditStatusText(row.audit_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="getTenantStatusType(row.status)">
{{ getTenantStatusText(row.status) }}
</el-tag>
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">
<el-icon><View /></el-icon>
<el-icon>
<View />
</el-icon>
查看
</el-button>
<el-button
size="small"
type="success"
v-if="row.audit_status === 'pending'"
@click="handleAudit(row)"
>
<el-icon><Check /></el-icon>
<el-button size="small" type="success" v-if="row.audit_status === 'pending'" @click="handleAudit(row)">
<el-icon>
<Check />
</el-icon>
审核
</el-button>
<el-button
size="small"
@click="handleEdit(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Edit /></el-icon>
<el-button size="small" @click="handleEdit(row)" v-if="row.audit_status !== 'approved'">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Delete /></el-icon>
<el-button size="small" type="danger" @click="handleDelete(row)" v-if="row.audit_status !== 'approved'">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
@ -113,36 +109,19 @@
</el-card>
<div class="pagination-wrapper">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
<el-pagination background :current-page="page" :page-size="pageSize" :total="total"
@current-change="handlePageChange" layout="total, prev, pager, next" />
</div>
</div>
<!-- 详情组件 -->
<TenantDetail
v-model="showViewDialog"
:tenant-id="currentTenant?.id"
/>
<TenantDetail v-model="showViewDialog" :tenant-id="currentTenant?.id" />
<!-- 审核组件 -->
<TenantAudit
v-model="showAuditDialog"
:tenant="currentTenant"
@success="handleAuditSuccess"
/>
<TenantAudit v-model="showAuditDialog" :tenant="currentTenant" @success="handleAuditSuccess" />
<!-- 编辑组件 -->
<TenantEdit
v-model="showEditDialog"
:tenant="currentTenant"
@success="handleEditSuccess"
/>
<TenantEdit v-model="showEditDialog" :tenant="currentTenant" @success="handleEditSuccess" />
</div>
</template>
@ -155,6 +134,7 @@ import { useDictStore } from '@/stores/dict';
import TenantDetail from './components/detail.vue';
import TenantAudit from './components/audit.vue';
import TenantEdit from './components/edit.vue';
import dayjs from 'dayjs';
//
const reviewStatusDict = ref<any[]>([]);
@ -229,6 +209,12 @@ const handlePageChange = (val: number) => {
fetchTenants();
};
//
function formatTime(time: string | null | undefined) {
if (!time) return '';
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
}
//
async function refresh() {
try {
@ -250,14 +236,14 @@ function getAuditStatusType(
if (dictItem) {
return dictItem.dict_tag_type || 'info';
}
//
const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> =
{
pending: 'warning',
approved: 'success',
rejected: 'danger',
};
{
pending: 'warning',
approved: 'success',
rejected: 'danger',
};
return statusMap[status] || 'info';
}
@ -269,7 +255,7 @@ function getAuditStatusText(status: string) {
if (dictItem) {
return dictItem.dict_label || String(status);
}
//
const statusMap: Record<string, string> = {
pending: '待审核',
@ -288,7 +274,7 @@ function getTenantStatusType(status: string | number): 'success' | 'info' | 'war
if (dictItem) {
return dictItem.dict_tag_type || 'info';
}
//
return String(status) === '1' ? 'success' : 'info';
}
@ -301,7 +287,7 @@ function getTenantStatusText(status: string | number): string {
if (dictItem) {
return dictItem.dict_label || String(status);
}
//
return String(status) === '1' ? '启用' : '禁用';
}
@ -471,5 +457,23 @@ onMounted(() => {
}
}
}
</style>
.pending-color {
color: #e6a23c;
background-color: #fdf6ec;
border-color: #f5dab1;
}
.approved-color,
.color-1 {
color: #67c23a;
background-color: rgb(240, 249, 235);
border-color: rgb(225, 243, 216);
}
.rejected-color {
color: #f56c6c;
background-color: #fef0f0;
border-color: #fbc4c4;
}
</style>

View File

@ -304,3 +304,47 @@ func (c *TaskController) DeleteTask() {
}
c.ServeJSON()
}
// 仪表盘获取待办任务
// GetTodoTasks 获取待办任务列表
// @router /api/oa/tasks/todo [get]
func (c *TaskController) GetTodoTasks() {
// 优先取查询参数,其次取中间件写入的上下文
tenantId, _ := c.GetInt("tenantId", 0)
if tenantId == 0 {
if v := c.Ctx.Input.GetData("tenantId"); v != nil {
if tid, ok := v.(int); ok {
tenantId = tid
}
}
}
id, _ := c.GetInt("id", 0)
if id == 0 {
if v := c.Ctx.Input.GetData("id"); v != nil {
if uid, ok := v.(int); ok {
id = uid
}
}
}
limit, _ := c.GetInt("limit", 10)
tasks, err := services.GetTodoTasks(tenantId, id, limit)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "获取待办任务失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "获取成功",
"data": tasks,
}
c.ServeJSON()
}

View File

@ -0,0 +1,20 @@
-- 反馈表sys_feedback修复TEXT字段默认值错误
CREATE TABLE `sys_feedback` (
`id` varchar(36) NOT NULL COMMENT 'ID',
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
`feedback_name` varchar(50) DEFAULT '' COMMENT '反馈人姓名',
`module` varchar(30) NOT NULL COMMENT '反馈对应模块',
`feedback_type` varchar(20) NOT NULL COMMENT '反馈类型',
`content` text NOT NULL COMMENT '反馈详细内容',
`attachment_url` varchar(255) DEFAULT '' COMMENT '附件URL',
`handle_status` varchar(20) NOT NULL DEFAULT '0' COMMENT '处理状态0-待处理/1-处理中/2-已解决/3-已驳回/4-无需处理)',
`handle_remark` text COMMENT '处理备注移除默认值TEXT类型不支持',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_tenant_id` (`tenant_id`) COMMENT '租户ID索引优化租户级查询',
KEY `idx_module` (`module`) COMMENT '模块索引,优化模块级反馈统计',
KEY `idx_handle_status` (`handle_status`) COMMENT '处理状态索引,优化待处理反馈查询',
KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引,优化时间范围查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通用反馈表(支持租户隔离、软删除)';

View File

@ -22,5 +22,8 @@ func main() {
}
})
// 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件
beego.SetStaticPath("/uploads", "uploads")
beego.Run()
}

View File

@ -48,6 +48,10 @@ func (f *FileInfo) TableName() string {
return "yz_files"
}
func init() {
// Removed duplicate ORM registration
}
// CanPreview 判断文件是否可以在线预览
func (f *FileInfo) CanPreview() bool {
previewableExts := map[string]bool{

View File

@ -121,3 +121,18 @@ func UpdateTask(t *Task, cols ...string) error {
_, err := o.Update(t, cols...)
return err
}
// GetTodoTasks 获取待办任务列表
func GetTodoTasks(tenantId int, id int, limit int) (tasks []*Task, err error) {
o := orm.NewOrm()
qs := o.QueryTable(new(Task)).Filter("tenant_id", tenantId).Filter("deleted_time__isnull", true)
// 如果传了用户,则筛选负责人为该用户的任务
if id > 0 {
qs = qs.Filter("principal_id", id)
}
if limit <= 0 {
limit = 10
}
_, err = qs.OrderBy("-id").Limit(limit).All(&tasks)
return
}

View File

@ -8,25 +8,26 @@ import (
)
type Tenant struct {
Id int `orm:"pk;auto" json:"id"`
Name string `orm:"size(100)" json:"name"`
Code string `orm:"size(50);unique" json:"code"`
Owner string `orm:"size(50)" json:"owner"`
Phone string `orm:"size(20);null" json:"phone"`
Email string `orm:"size(100);null" json:"email"`
Status int `orm:"default(1)" json:"status"`
AuditStatus string `orm:"size(20);default(pending)" json:"audit_status"`
AuditComment string `orm:"type(text);null" json:"audit_comment"`
AuditBy string `orm:"size(50);null" json:"audit_by"`
AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"`
Capacity int `orm:"default(0)" json:"capacity"`
CapacityUsed int `orm:"default(0)" json:"capacity_used"`
Remark string `orm:"type(text);null" json:"remark"`
CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"null;type(datetime)" json:"delete_time"`
CreateBy string `orm:"size(50);null" json:"create_by"`
UpdateBy string `orm:"size(50);null" json:"update_by"`
Id int `orm:"pk;auto" json:"id"`
Name string `orm:"size(100)" json:"name"`
Code string `orm:"size(50);unique" json:"code"`
Owner string `orm:"size(50)" json:"owner"`
Phone string `orm:"size(20);null" json:"phone"`
Email string `orm:"size(100);null" json:"email"`
Status int `orm:"default(1)" json:"status"`
AuditStatus string `orm:"size(20);default(pending)" json:"audit_status"`
AuditComment string `orm:"type(text);null" json:"audit_comment"`
AuditBy string `orm:"size(50);null" json:"audit_by"`
AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"`
Capacity int `orm:"default(0)" json:"capacity"`
CapacityUsed int `orm:"default(0)" json:"capacity_used"`
AttachmentUrl string `orm:"size(255);null" json:"attachment_url"`
Remark string `orm:"type(text);null" json:"remark"`
CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"null;type(datetime)" json:"delete_time"`
CreateBy string `orm:"size(50);null" json:"create_by"`
UpdateBy string `orm:"size(50);null" json:"update_by"`
}
// TableName 设置表名

View File

@ -270,6 +270,8 @@ func init() {
// 文件管理路由 - 手动配置以匹配前端的 /api/files 路径
beego.Router("/api/files", &controllers.FileController{}, "get:GetAllFiles")
beego.Router("/api/files", &controllers.FileController{}, "post:Post")
// 兼容前端上传地址 /api/files/upload -> 复用 Post 处理上传
beego.Router("/api/files/upload", &controllers.FileController{}, "post:Post")
beego.Router("/api/files/my", &controllers.FileController{}, "get:GetMyFiles")
beego.Router("/api/files/download/:id", &controllers.FileController{}, "get:DownloadFile")
beego.Router("/api/files/preview/:id", &controllers.FileController{}, "get:PreviewFile")
@ -315,6 +317,7 @@ func init() {
// OA任务管理路由
beego.Router("/api/oa/tasks", &controllers.TaskController{}, "get:GetTasks;post:CreateTask")
beego.Router("/api/oa/tasks/:id", &controllers.TaskController{}, "get:GetTaskById;put:UpdateTask;delete:DeleteTask")
beego.Router("/api/oa/tasks/todo", &controllers.TaskController{}, "get:GetTodoTasks")
// 权限管理路由
beego.Router("/api/permissions/menus", &controllers.PermissionController{}, "get:GetAllMenuPermissions")

View File

@ -69,3 +69,8 @@ func DeleteOATask(id int, operatorName string, operatorId int) error {
func genTaskNo(tenantId int) string {
return fmt.Sprintf("TASK%d%s", tenantId, time.Now().Format("20060102150405"))
}
// 仪表盘获取待办任务
func GetTodoTasks(tenantId int, id int, limit int) (tasks []*models.Task, err error) {
return models.GetTodoTasks(tenantId, id, limit)
}