优化仪表盘等

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' import request from '@/utils/request'
/**
* 获取任务列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function listTasks(params) { export function listTasks(params) {
return request({ return request({
url: '/api/oa/tasks', url: '/api/oa/tasks',
@ -8,6 +13,11 @@ export function listTasks(params) {
}) })
} }
/**
* 获取任务详情
* @param {number|string} id 任务ID
* @returns {Promise}
*/
export function getTask(id) { export function getTask(id) {
return request({ return request({
url: `/api/oa/tasks/${id}`, url: `/api/oa/tasks/${id}`,
@ -15,6 +25,11 @@ export function getTask(id) {
}) })
} }
/**
* 创建任务
* @param {Object} data 任务数据
* @returns {Promise}
*/
export function createTask(data) { export function createTask(data) {
return request({ return request({
url: '/api/oa/tasks', 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) { export function updateTask(id, data) {
return request({ return request({
url: `/api/oa/tasks/${id}`, 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) { export function deleteTask(id) {
return request({ return request({
url: `/api/oa/tasks/${id}`, url: `/api/oa/tasks/${id}`,
method: 'delete' 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,35 +5,21 @@
<i class="fa fa-bars"></i> <i class="fa fa-bars"></i>
</el-button> </el-button>
<el-breadcrumb separator="/" class="bread"> <el-breadcrumb separator="/" class="bread">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }" style="position: relative !important; top: 1px !important;">首页</el-breadcrumb-item>
<el-breadcrumb-item <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index"
v-for="(item, index) in breadcrumbs" :to="(item.label === '首页' || index === breadcrumbs.length - 1) ? { path: item.path } : null" style="position: relative !important; top: 1px !important;">
:key="index"
:to="(item.label === '首页' || index === breadcrumbs.length - 1) ? { path: item.path } : null"
>
{{ item.label }} {{ item.label }}
</el-breadcrumb-item> </el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="r-content"> <div class="r-content">
<!-- 更新缓存按钮 --> <!-- 更新缓存按钮 -->
<el-button <el-button circle :icon="Refresh" @click="refreshCache" class="refresh-cache-btn" :loading="cacheLoading"
circle title="更新菜单缓存" />
:icon="Refresh"
@click="refreshCache"
class="refresh-cache-btn"
:loading="cacheLoading"
title="更新菜单缓存"
/>
<!-- 主题切换按钮 --> <!-- 主题切换按钮 -->
<el-button <el-button circle :icon="themeIcon" @click="toggleTheme" class="theme-toggle-btn"
circle :title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
:icon="themeIcon"
@click="toggleTheme"
class="theme-toggle-btn"
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
/>
<el-dropdown trigger="click" @command="handleCommand"> <el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link" style="cursor: pointer;"> <span class="el-dropdown-link" style="cursor: pointer;">
@ -43,11 +29,15 @@
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="profile"> <el-dropdown-item command="profile">
<el-icon><User /></el-icon> <el-icon>
<User />
</el-icon>
<span>个人中心</span> <span>个人中心</span>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided command="logout"> <el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon> <el-icon>
<SwitchButton />
</el-icon>
<span>退出登录</span> <span>退出登录</span>
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
@ -56,6 +46,7 @@
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
@ -437,6 +428,7 @@ onUnmounted(() => {
} }
:deep(.bread) { :deep(.bread) {
// 使 // 使
.el-breadcrumb__inner { .el-breadcrumb__inner {
color: #ffffff !important; color: #ffffff !important;
@ -451,4 +443,8 @@ onUnmounted(() => {
} }
} }
} }
:deep(.el-tabs__nav){
height: 36px !important;
}
</style> </style>

View File

@ -9,6 +9,22 @@
<el-descriptions-item label="进度">{{ Number(task.progress||0) }}%</el-descriptions-item> <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">{{ 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">{{ 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> </el-descriptions>
<template #footer> <template #footer>
<el-button @click="visible=false">关闭</el-button> <el-button @click="visible=false">关闭</el-button>
@ -19,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { useDictStore } from '@/stores/dict' import { useDictStore } from '@/stores/dict'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, task: any }>() const props = defineProps<{ modelValue: boolean, task: any }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -39,7 +56,51 @@ const formatDateTime = (v: any) => {
const pad = (n: number) => String(n).padStart(2, '0') 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())}` 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> </script>
<style scoped> <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> </style>

View File

@ -46,6 +46,25 @@
<el-form-item label="描述" prop="task_desc"> <el-form-item label="描述" prop="task_desc">
<el-input v-model="form.task_desc" type="textarea" :rows="4" /> <el-input v-model="form.task_desc" type="textarea" :rows="4" />
</el-form-item> </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> </el-form>
<EmployeeSelectDialog <EmployeeSelectDialog
v-model:visible="employeeDialogVisible" v-model:visible="employeeDialogVisible"
@ -72,6 +91,9 @@ import { useDictStore } from '@/stores/dict'
import EmployeeSelectDialog from './EmployeeSelectDialog.vue' import EmployeeSelectDialog from './EmployeeSelectDialog.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getTenantEmployees } from '@/api/employee' 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<{ const props = defineProps<{
modelValue: boolean, modelValue: boolean,
@ -105,6 +127,22 @@ const form = reactive<any>({
// id team_employee_ids // id team_employee_ids
const teamEmployeeIds = ref<Array<number | string>>([]) const teamEmployeeIds = ref<Array<number | string>>([])
const teamEmployeeList = ref<Array<{id: number|string, name: 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 authStore = useAuthStore()
const loadTeamEmployeeNames = async (ids: Array<number | string>) => { const loadTeamEmployeeNames = async (ids: Array<number | string>) => {
if (!ids || ids.length === 0) { if (!ids || ids.length === 0) {
@ -183,6 +221,16 @@ watch(() => props.task, (t) => {
} else { } else {
loadTeamEmployeeNames(teamEmployeeIds.value) 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 }) }, { immediate: true })
const rules = reactive<FormRules<any>>({ const rules = reactive<FormRules<any>>({
@ -239,6 +287,14 @@ const onSubmit = async () => {
const n = Number(x) const n = Number(x)
return isNaN(n) ? x : n 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) { if (props.isEdit && form.id) {
res = await updateTask(form.id, payload) res = await updateTask(form.id, payload)
@ -264,6 +320,63 @@ const onSubmit = async () => {
const onClosed = () => { const onClosed = () => {
// reset if needed // 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> </script>
<style scoped> <style scoped>

View File

@ -46,7 +46,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button link size="small" @click="openDetail(row)">查看</el-button> <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="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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -87,6 +87,7 @@ import { listTasks, getTask, deleteTask } from '@/api/tasks'
import Edit from './components/edit.vue' import Edit from './components/edit.vue'
import Detail from './components/detail.vue' import Detail from './components/detail.vue'
import { useDictStore } from '@/stores/dict' import { useDictStore } from '@/stores/dict'
import { useAuthStore } from '@/stores/auth'
const loading = ref(false) const loading = ref(false)
const total = ref(0) const total = ref(0)
@ -110,6 +111,8 @@ const currentTask = ref<any>(null)
const detailVisible = ref(false) const detailVisible = ref(false)
const deleteDialogVisible = ref(false) // const deleteDialogVisible = ref(false) //
const authStore = useAuthStore()
const fetchList = async () => { const fetchList = async () => {
loading.value = true loading.value = true
try { try {
@ -179,6 +182,13 @@ const openDelete = (row: any) => {
.catch(() => {}) .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) => { const priorityTagType = (v: string) => {
if (v === 'urgent') return 'danger' if (v === 'urgent') return 'danger'
if (v === 'high') return 'warning' if (v === 'high') return 'warning'

View File

@ -356,6 +356,7 @@ import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { getKnowledgeList } from "@/api/knowledge"; import { getKnowledgeList } from "@/api/knowledge";
import { getTodoTasks } from "@/api/tasks";
import { import {
FolderOpened, FolderOpened,
StarFilled, StarFilled,
@ -605,78 +606,24 @@ const industryNews = ref([
const sortType = ref("time"); const sortType = ref("time");
// //
const tasks = ref([ const tasks = ref([]);
{
id: 1, async function fetchTodoTasks() {
title: "设计部李亦想的加班申请流程", try {
node: "直属领导审批", const tenantId = authStore?.user?.tenant_id || 0;
applyTime: "2024-01-20 13:32", const res = await getTodoTasks({ tenantId, limit: 10 });
system: "oa", const list = res?.data?.list || res?.data || [];
}, tasks.value = list.map((item) => ({
{ id: item.id,
id: 2, title: item.task_name || item.title || "",
title: "财务部张申请的报销流程", node: item.node || item.task_status || "",
node: "财务审核", applyTime: item.apply_time || item.created_time || item.createdAt || "",
applyTime: "2024-01-19 10:15", system: item.system || "oa",
system: "finance", }));
}, } catch (e) {
{ console.error("获取待办任务失败", e);
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 filteredTasks = computed(() => { const filteredTasks = computed(() => {
return tasks.value; return tasks.value;
@ -885,6 +832,8 @@ onMounted(() => {
document.addEventListener("click", handleClickOutside); document.addEventListener("click", handleClickOutside);
// //
fetchKnowledgeList(); fetchKnowledgeList();
//
fetchTodoTasks();
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -93,16 +93,19 @@
</div> </div>
<div class="list-content"> <div class="list-content">
<div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }"> <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-info">
<div class="task-title"> <div class="task-title">
{{ task.title }} {{ task.title }}
<el-tag :type="getPriorityType(task.priority)" size="small" effect="plain"> <el-tag :type="task.priorityTagType || getPriorityType(task.priority)" size="small" effect="plain" :class="'priority-' + task.priority">
{{ task.priority }} {{ task.priorityLabel || task.priority }}
</el-tag> </el-tag>
</div> </div>
<div class="task-meta"> <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> </div>
</div> </div>
@ -164,8 +167,10 @@ import {
View, View,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { getKnowledgeCount } from "@/api/knowledge"; import { getKnowledgeCount } from "@/api/knowledge";
import { getTodoTasks } from "@/api/tasks";
import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard"; import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useDictStore } from '@/stores/dict'
Chart.register(...registerables); Chart.register(...registerables);
@ -179,6 +184,14 @@ const currentDate = computed(() => {
return `${month}${day}${weekday}`; 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 // 使 markRaw
const stats = ref([ const stats = ref([
{ {
@ -288,56 +301,48 @@ const fetchTenantStats = async () => {
const taskCurrentPage = ref(1); const taskCurrentPage = ref(1);
const taskPageSize = ref(5); const taskPageSize = ref(5);
const tasks = ref([ const tasks = ref<any[]>([]);
{ const priorityDict = ref<any[]>([]);
title: "完成Q2预算审核",
date: "2024-06-11", async function fetchPriorityDict() {
priority: "High", try {
completed: false, const dictStore = useDictStore();
}, priorityDict.value = await dictStore.getDictItems('task_priority');
{ } catch (e) {
title: "发邮件通知团队", // ignore
date: "2024-06-10", }
priority: "Medium", }
completed: true,
}, async function fetchTodoTasksDashboard() {
{ try {
title: "产品需求讨论会", const tenantId = authStore?.user?.tenant_id || 0;
date: "2024-06-09", const id = authStore?.user?.id || 0;
priority: "Low", const res = await getTodoTasks({ tenantId, id, limit: 20 });
completed: false, const list = res?.data?.list || res?.data || [];
}, //
{ tasks.value = list.map((item: any) => ({
title: "准备季度报告", title: item.task_name || item.title || "",
date: "2024-06-12", date: item.created_time || item.apply_time || item.updated_time || "",
priority: "High", priority: item.priority || "",
completed: false, 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');
title: "更新项目文档", })(),
date: "2024-06-13", priorityTagType: (() => {
priority: "Medium", const dictItem = priorityDict.value.find((d: any) => String(d.dict_value) === String(item.priority));
completed: false, return dictItem?.dict_tag_type || undefined;
}, })(),
{ completed: item.task_status === 'completed' || item.task_status === 'closed',
title: "代码审查", }));
date: "2024-06-14", } catch (e) {
priority: "Low", console.warn('获取待办任务失败', e);
completed: false, }
}, }
{
title: "客户需求沟通", function capitalize(s: string) {
date: "2024-06-15", if (!s) return s;
priority: "High", return s.charAt(0).toUpperCase() + s.slice(1);
completed: false, }
},
{
title: "测试环境部署",
date: "2024-06-16",
priority: "Medium",
completed: false,
},
]);
// //
const paginatedTasks = computed(() => { const paginatedTasks = computed(() => {
@ -445,13 +450,13 @@ onMounted(() => {
// //
fetchActivityLogs(); fetchActivityLogs();
//
fetchPriorityDict();
fetchTodoTasksDashboard();
// 线 // 线
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null; const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
if (!lineChartEl) { if (lineChartEl) {
console.error("Line chart element not found");
return;
}
new Chart(lineChartEl, { new Chart(lineChartEl, {
type: "line", type: "line",
data: { data: {
@ -509,14 +514,11 @@ onMounted(() => {
}, },
}, },
}); });
}
// //
const barChartEl = document.getElementById("barChart") as HTMLCanvasElement | null; const barChartEl = document.getElementById("barChart") as HTMLCanvasElement | null;
if (!barChartEl) { if (barChartEl) {
console.error("Bar chart element not found");
return;
}
new Chart(barChartEl, { new Chart(barChartEl, {
type: "bar", type: "bar",
data: { data: {
@ -569,6 +571,7 @@ onMounted(() => {
}, },
}, },
}); });
}
}); });
</script> </script>
@ -822,6 +825,7 @@ onMounted(() => {
&.done { &.done {
.task-info { .task-info {
margin-left: 20px;
.task-title { .task-title {
text-decoration: line-through; text-decoration: line-through;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
@ -978,4 +982,22 @@ onMounted(() => {
grid-template-columns: 1fr; 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> </style>

View File

@ -1,11 +1,5 @@
<template> <template>
<el-dialog <el-dialog title="租户详情" v-model="visible" width="600px" :close-on-click-modal="false" @close="handleClose">
title="租户详情"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="tenant-detail" v-loading="loading"> <div class="tenant-detail" v-loading="loading">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="租户ID"> <el-descriptions-item label="租户ID">
@ -14,9 +8,6 @@
<el-descriptions-item label="租户名称"> <el-descriptions-item label="租户名称">
{{ tenantData.name }} {{ tenantData.name }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ tenantData.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人"> <el-descriptions-item label="负责人">
{{ tenantData.owner }} {{ tenantData.owner }}
</el-descriptions-item> </el-descriptions-item>
@ -27,12 +18,12 @@
{{ tenantData.email || '未设置' }} {{ tenantData.email || '未设置' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="审核状态"> <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) }} {{ getAuditStatusText(tenantData.audit_status) }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="getTenantStatusType(tenantData.status)"> <el-tag :type="getTenantStatusType(tenantData.status)" :class="`color-${tenantData.status}`">
{{ getTenantStatusText(tenantData.status) }} {{ getTenantStatusText(tenantData.status) }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
@ -40,19 +31,28 @@
<span>{{ formatCapacity(tenantData.capacity) }}</span> <span>{{ formatCapacity(tenantData.capacity) }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="已使用"> <el-descriptions-item label="已使用">
<div style="display: flex;flex-direction: column;">
<span>{{ formatCapacity(tenantData.capacity_used || 0) }}</span> <span>{{ formatCapacity(tenantData.capacity_used || 0) }}</span>
<el-progress <el-progress :percentage="getCapacityPercentage(tenantData.capacity, tenantData.capacity_used)"
:percentage="getCapacityPercentage(tenantData.capacity, tenantData.capacity_used)" :color="getCapacityColor(tenantData.capacity, tenantData.capacity_used)" :stroke-width="6"
:color="getCapacityColor(tenantData.capacity, tenantData.capacity_used)" style="margin-top: 8px;" />
:stroke-width="6" </div>
style="margin-top: 8px; width: 200px;" </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>
<el-descriptions-item label="创建时间"> <el-descriptions-item label="创建时间">
{{ tenantData.created_at }} {{ formatTime(tenantData.created_at) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="更新时间"> <el-descriptions-item label="更新时间">
{{ tenantData.updated_at || '未更新' }} {{ formatTime(tenantData.updated_at) || '未更新' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="备注" :span="2"> <el-descriptions-item label="备注" :span="2">
{{ tenantData.remark || '无' }} {{ tenantData.remark || '无' }}
@ -69,7 +69,9 @@
import { ref, watch, computed, onMounted } from 'vue'; import { ref, watch, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { getTenantDetail } from '@/api/tenant'; import { getTenantDetail } from '@/api/tenant';
import request from '@/utils/request';
import { useDictStore } from '@/stores/dict'; import { useDictStore } from '@/stores/dict';
import dayjs from 'dayjs';
interface Props { interface Props {
modelValue: boolean; modelValue: boolean;
@ -126,6 +128,11 @@ function getAuditStatusType(status: string): 'warning' | 'success' | 'danger' |
return dictItem.dict_tag_type || 'info'; 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'> = { const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> = {
pending: 'warning', pending: 'warning',
@ -213,6 +220,18 @@ function getCapacityColor(capacity: number | undefined, used: number | undefined
return '#67c23a'; // 绿 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() { async function fetchDetail() {
if (!props.tenantId) return; if (!props.tenantId) return;
@ -277,6 +296,11 @@ watch(
margin-top: 0; margin-top: 0;
} }
:deep(.el-descriptions__cell) {
width: 180px;
text-align: right;
}
:deep(.el-descriptions-item__label) { :deep(.el-descriptions-item__label) {
font-weight: 600; font-weight: 600;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
@ -286,5 +310,23 @@ watch(
color: var(--el-text-color-primary); 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-form-item label="租户名称" prop="name">
<el-input v-model="tenantForm.name" placeholder="请输入租户名称" /> <el-input v-model="tenantForm.name" placeholder="请输入租户名称" />
</el-form-item> </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-form-item label="负责人" prop="owner">
<el-input v-model="tenantForm.owner" placeholder="请输入负责人" /> <el-input v-model="tenantForm.owner" placeholder="请输入负责人" />
</el-form-item> </el-form-item>
@ -56,6 +53,24 @@
placeholder="请输入备注" placeholder="请输入备注"
/> />
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button @click="handleClose">取消</el-button> <el-button @click="handleClose">取消</el-button>
@ -73,8 +88,14 @@ import {
ElMessageBox, ElMessageBox,
type FormInstance, type FormInstance,
type FormRules, type FormRules,
type UploadFile,
type UploadUserFile,
type UploadRequestOptions,
} from 'element-plus'; } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { createTenant, updateTenant } from '@/api/tenant'; import { createTenant, updateTenant } from '@/api/tenant';
import { uploadFile } from '@/api/file';
import request from '@/utils/request';
import { useDictStore } from '@/stores/dict'; import { useDictStore } from '@/stores/dict';
interface Props { interface Props {
@ -99,6 +120,9 @@ const visible = computed({
const submitting = ref(false); const submitting = ref(false);
const tenantFormRef = ref<FormInstance>(); const tenantFormRef = ref<FormInstance>();
const uploadFileList = ref<UploadUserFile[]>([]);
const previewVisible = ref(false);
const previewUrl = ref('');
// //
const dictStore = useDictStore(); const dictStore = useDictStore();
@ -122,18 +146,17 @@ const isEditing = computed(() => {
const tenantForm = reactive({ const tenantForm = reactive({
id: null as number | null, id: null as number | null,
name: '', name: '',
code: '',
owner: '', owner: '',
phone: '', phone: '',
email: '', email: '',
status: '1', // dict_value status: '1', // dict_value
capacity: 0, capacity: 0,
remark: '', remark: '',
attachment_url: '' as string,
}); });
const formRules: FormRules = { const formRules: FormRules = {
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }], name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入租户编码', trigger: 'blur' }],
owner: [{ required: true, message: '请输入负责人', trigger: 'blur' }], owner: [{ required: true, message: '请输入负责人', trigger: 'blur' }],
phone: [ phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }, { 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() { function resetForm() {
tenantForm.id = null; tenantForm.id = null;
tenantForm.name = ''; tenantForm.name = '';
tenantForm.code = '';
tenantForm.owner = ''; tenantForm.owner = '';
tenantForm.phone = ''; tenantForm.phone = '';
tenantForm.email = ''; tenantForm.email = '';
tenantForm.status = '1'; // dict_value tenantForm.status = '1'; // dict_value
tenantForm.capacity = 0; tenantForm.capacity = 0;
tenantForm.remark = ''; tenantForm.remark = '';
tenantForm.attachment_url = '';
uploadFileList.value = [];
tenantFormRef.value?.resetFields(); tenantFormRef.value?.resetFields();
} }
@ -165,7 +201,6 @@ function initFormData() {
if (props.tenant && props.tenant.id) { if (props.tenant && props.tenant.id) {
tenantForm.id = props.tenant.id; tenantForm.id = props.tenant.id;
tenantForm.name = props.tenant.name || ''; tenantForm.name = props.tenant.name || '';
tenantForm.code = props.tenant.code || '';
tenantForm.owner = props.tenant.owner || ''; tenantForm.owner = props.tenant.owner || '';
tenantForm.phone = props.tenant.phone || ''; tenantForm.phone = props.tenant.phone || '';
tenantForm.email = props.tenant.email || ''; tenantForm.email = props.tenant.email || '';
@ -174,6 +209,10 @@ function initFormData() {
// capacity MB使 // capacity MB使
tenantForm.capacity = props.tenant.capacity != null ? Number(props.tenant.capacity) : 0; tenantForm.capacity = props.tenant.capacity != null ? Number(props.tenant.capacity) : 0;
tenantForm.remark = props.tenant.remark || ''; tenantForm.remark = props.tenant.remark || '';
tenantForm.attachment_url = props.tenant.attachment_url || '';
uploadFileList.value = tenantForm.attachment_url
? [{ name: '附件', url: resolveFileUrl(tenantForm.attachment_url) }]
: [];
} else { } else {
resetForm(); resetForm();
} }
@ -188,7 +227,6 @@ async function handleSubmit() {
try { try {
const tenantData = { const tenantData = {
name: tenantForm.name, name: tenantForm.name,
code: tenantForm.code,
owner: tenantForm.owner, owner: tenantForm.owner,
phone: tenantForm.phone || '', phone: tenantForm.phone || '',
email: tenantForm.email || '', email: tenantForm.email || '',
@ -196,6 +234,7 @@ async function handleSubmit() {
// capacity 使 MB // capacity 使 MB
capacity: tenantForm.capacity || 0, capacity: tenantForm.capacity || 0,
remark: tenantForm.remark || '', remark: tenantForm.remark || '',
attachment_url: tenantForm.attachment_url || '',
}; };
let res; let res;
@ -253,6 +292,55 @@ watch(
}, },
{ deep: true } { 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> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -262,4 +350,3 @@ watch(
margin-top: 4px; margin-top: 4px;
} }
</style> </style>

View File

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

View File

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

View File

@ -121,3 +121,18 @@ func UpdateTask(t *Task, cols ...string) error {
_, err := o.Update(t, cols...) _, err := o.Update(t, cols...)
return err 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

@ -21,6 +21,7 @@ type Tenant struct {
AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"` AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"`
Capacity int `orm:"default(0)" json:"capacity"` Capacity int `orm:"default(0)" json:"capacity"`
CapacityUsed int `orm:"default(0)" json:"capacity_used"` 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"` Remark string `orm:"type(text);null" json:"remark"`
CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"` CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"` UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"`

View File

@ -270,6 +270,8 @@ func init() {
// 文件管理路由 - 手动配置以匹配前端的 /api/files 路径 // 文件管理路由 - 手动配置以匹配前端的 /api/files 路径
beego.Router("/api/files", &controllers.FileController{}, "get:GetAllFiles") beego.Router("/api/files", &controllers.FileController{}, "get:GetAllFiles")
beego.Router("/api/files", &controllers.FileController{}, "post:Post") 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/my", &controllers.FileController{}, "get:GetMyFiles")
beego.Router("/api/files/download/:id", &controllers.FileController{}, "get:DownloadFile") beego.Router("/api/files/download/:id", &controllers.FileController{}, "get:DownloadFile")
beego.Router("/api/files/preview/:id", &controllers.FileController{}, "get:PreviewFile") beego.Router("/api/files/preview/:id", &controllers.FileController{}, "get:PreviewFile")
@ -315,6 +317,7 @@ func init() {
// OA任务管理路由 // OA任务管理路由
beego.Router("/api/oa/tasks", &controllers.TaskController{}, "get:GetTasks;post:CreateTask") 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/: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") 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 { func genTaskNo(tenantId int) string {
return fmt.Sprintf("TASK%d%s", tenantId, time.Now().Format("20060102150405")) 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)
}