优化日程增加文件上传功能
This commit is contained in:
@@ -57,6 +57,15 @@ export function carrySchedule(id, data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 更新日程的相关图片:images 为 [{id, url, name}],由后端序列化存储
|
||||
export function updateScheduleImages(id, images) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/images/${id}`,
|
||||
method: 'post',
|
||||
data: { images }
|
||||
})
|
||||
}
|
||||
|
||||
// 批量顺延指定日期的全部未完成日程
|
||||
export function carryPendingSchedules(data) {
|
||||
return request({
|
||||
|
||||
@@ -52,6 +52,52 @@
|
||||
<span>{{ formatDateTime(schedule.create_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关图片:可选择文件上传,也可在抽屉打开时直接 Ctrl+V 粘贴截图 -->
|
||||
<div class="detail-images">
|
||||
<div class="images-header">
|
||||
<span class="images-title">相关图片</span>
|
||||
<span v-if="editable" class="images-tip">支持粘贴截图</span>
|
||||
<el-upload
|
||||
v-if="editable"
|
||||
class="images-upload"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUploadRequest"
|
||||
accept="image/*"
|
||||
multiple
|
||||
>
|
||||
<el-button size="small" :icon="PictureFilled" :loading="uploading"
|
||||
>上传图片</el-button
|
||||
>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div v-if="imageList.length" class="image-grid">
|
||||
<div
|
||||
v-for="(img, index) in imageList"
|
||||
:key="`${img.id}-${img.url}`"
|
||||
class="image-cell"
|
||||
>
|
||||
<el-image
|
||||
:src="resolveUrl(img.url)"
|
||||
:preview-src-list="previewList"
|
||||
:initial-index="index"
|
||||
fit="cover"
|
||||
class="image-thumb"
|
||||
preview-teleported
|
||||
/>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
class="image-remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
:icon="Delete"
|
||||
@click="handleRemoveImage(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="image-empty">暂无相关图片</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
@@ -87,12 +133,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
CircleCheck,
|
||||
Delete,
|
||||
PictureFilled,
|
||||
Promotion,
|
||||
RefreshLeft
|
||||
} from "@element-plus/icons-vue";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import { updateScheduleImages } from "@/api/oaSchedule";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -116,7 +167,8 @@ const emit = defineEmits([
|
||||
"edit",
|
||||
"finish",
|
||||
"revert",
|
||||
"postpone"
|
||||
"postpone",
|
||||
"changed"
|
||||
]);
|
||||
|
||||
const visible = computed({
|
||||
@@ -171,6 +223,185 @@ const statusText = computed(() => {
|
||||
: "待办";
|
||||
});
|
||||
|
||||
// ---------- 相关图片(上传 / 粘贴截图 / 预览 / 删除) ----------
|
||||
const imageList = ref([]);
|
||||
const uploading = ref(false);
|
||||
// 已上传待保存的图片 + 串行保存队列:多次上传并发时避免互相覆盖
|
||||
let pendingImages = [];
|
||||
let saveChain = Promise.resolve();
|
||||
|
||||
function parseImages(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(img => img && img.url);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(img => img && img.url);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 后端返回的可能是相对路径,补全为可访问的绝对地址
|
||||
function resolveUrl(url) {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
/^https?:\/\//i.test(url) ||
|
||||
url.startsWith("data:") ||
|
||||
url.startsWith("blob:")
|
||||
) {
|
||||
return url;
|
||||
}
|
||||
return `${window.location.origin}/${String(url).replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
const previewList = computed(() =>
|
||||
imageList.value.map(img => resolveUrl(img.url))
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.schedule,
|
||||
schedule => {
|
||||
imageList.value = parseImages(schedule?.images);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function validateImage(file) {
|
||||
if (file.type && !file.type.startsWith("image/")) {
|
||||
ElMessage.error("只支持上传图片文件");
|
||||
return false;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error(`「${file.name}」超过 10MB,请压缩后再上传`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 粘贴的截图常常没有文件名/扩展名,后端按扩展名识别类型,这里补齐
|
||||
function ensureImageName(file) {
|
||||
if (/\.(jpe?g|png|gif|bmp|webp)$/i.test(file.name || "")) {
|
||||
return file;
|
||||
}
|
||||
const ext = String(file.type || "image/png").split("/")[1] || "png";
|
||||
return new File([file], `粘贴截图-${Date.now()}.${ext === "jpeg" ? "jpg" : ext}`, {
|
||||
type: file.type || "image/png"
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await uploadFile(formData);
|
||||
if (res?.code !== 200 && res?.code !== 201) {
|
||||
throw new Error(res?.msg || `「${file.name}」上传失败`);
|
||||
}
|
||||
const data = res?.data || {};
|
||||
return {
|
||||
id: data.id || 0,
|
||||
url: data.url || data.src || "",
|
||||
name: data.name || file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function persistImages(list) {
|
||||
if (!props.schedule?.id) {
|
||||
return;
|
||||
}
|
||||
const res = await updateScheduleImages(
|
||||
props.schedule.id,
|
||||
list.map(img => ({ id: img.id || 0, url: img.url, name: img.name || "" }))
|
||||
);
|
||||
if (res?.code !== 200) {
|
||||
ElMessage.error(res?.msg || "保存图片失败");
|
||||
return;
|
||||
}
|
||||
imageList.value = parseImages(res?.data?.images);
|
||||
emit("changed", props.schedule.id);
|
||||
}
|
||||
|
||||
function enqueue(task) {
|
||||
saveChain = saveChain.then(task).catch(() => {});
|
||||
return saveChain;
|
||||
}
|
||||
|
||||
// 把已上传的图片追加到已有列表后保存(读取最新列表,避免覆盖)
|
||||
function flushPending() {
|
||||
return enqueue(async () => {
|
||||
if (!pendingImages.length) {
|
||||
return;
|
||||
}
|
||||
const added = pendingImages;
|
||||
pendingImages = [];
|
||||
await persistImages([...imageList.value, ...added]);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveImage(index) {
|
||||
const next = imageList.value.filter((_, i) => i !== index);
|
||||
enqueue(() => persistImages(next));
|
||||
}
|
||||
|
||||
async function addFiles(files) {
|
||||
const valid = files.map(ensureImageName).filter(validateImage);
|
||||
if (!valid.length) {
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const uploaded = [];
|
||||
for (const file of valid) {
|
||||
uploaded.push(await uploadImage(file));
|
||||
}
|
||||
pendingImages.push(...uploaded);
|
||||
await flushPending();
|
||||
ElMessage.success(`已添加 ${uploaded.length} 张图片`);
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// el-upload 自定义上传:多选时每个文件触发一次,统一走 addFiles 串行保存
|
||||
function handleUploadRequest(options) {
|
||||
return addFiles([options?.file].filter(Boolean));
|
||||
}
|
||||
|
||||
// 抽屉打开时直接 Ctrl+V 粘贴剪贴板里的截图
|
||||
function handlePaste(event) {
|
||||
if (!visible.value || !props.editable) {
|
||||
return;
|
||||
}
|
||||
// DataTransferItemList 只保证类数组,先转数组再遍历
|
||||
const items = Array.from(event.clipboardData?.items || []);
|
||||
const files = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && String(item.type || "").startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
addFiles(files);
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("paste", handlePaste));
|
||||
onBeforeUnmount(() => document.removeEventListener("paste", handlePaste));
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -236,4 +467,65 @@ function formatDateTime(value) {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 相关图片 */
|
||||
.detail-images {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f2f4f8;
|
||||
}
|
||||
|
||||
.images-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.images-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.images-tip {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.image-cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-thumb {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.image-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.image-cell:hover .image-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-empty {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<el-form-item label="日程标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
maxlength="80"
|
||||
maxlength="200"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
show-word-limit
|
||||
placeholder="请输入日程标题"
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
<div class="day-sub">
|
||||
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
|
||||
>,{{ pendingCount }} 项未完成</template
|
||||
><template v-if="selectedPendingIds.length > 0"
|
||||
>,已选 {{ selectedPendingIds.length }} 项</template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +143,9 @@
|
||||
size="small"
|
||||
:icon="Promotion"
|
||||
@click="handleCarryPending"
|
||||
>延期未完成</el-button
|
||||
>延期<template v-if="selectedPendingIds.length > 0"
|
||||
>({{ selectedPendingIds.length }})</template
|
||||
></el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -160,8 +164,11 @@
|
||||
:style="{ '--item-color': item.color }"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="item.status === 1"
|
||||
@change="toggleFinish(item)"
|
||||
:model-value="selectedIds.includes(item.id)"
|
||||
:disabled="item.status === 1"
|
||||
:title="item.status === 1 ? '已完成的日程无需延期' : '勾选后批量延期'"
|
||||
@click.stop
|
||||
@change="checked => toggleSelect(item.id, checked)"
|
||||
/>
|
||||
<div class="day-item-main" @click="openDetail(item)">
|
||||
<div class="day-item-title" :class="{ done: item.status === 1 }">
|
||||
@@ -205,6 +212,9 @@
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="finish">{{
|
||||
item.status === 1 ? "取消完成" : "标记完成"
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="postpone"
|
||||
:disabled="item.status === 1"
|
||||
@@ -342,6 +352,7 @@
|
||||
@finish="handleDetailFinish"
|
||||
@revert="handleDetailRevert"
|
||||
@postpone="handleDetailPostpone"
|
||||
@changed="reloadAll"
|
||||
/>
|
||||
|
||||
<!-- 延期对话框 -->
|
||||
@@ -370,6 +381,7 @@ import ScheduleDetail from "./components/detail.vue";
|
||||
import SchedulePostpone from "./components/postponeDialog.vue";
|
||||
import {
|
||||
carryPendingSchedules,
|
||||
carrySchedule,
|
||||
deleteSchedule,
|
||||
finishSchedule,
|
||||
getScheduleList,
|
||||
@@ -475,6 +487,10 @@ const calendarMap = computed(() => {
|
||||
}
|
||||
map[item.schedule_date].push(item);
|
||||
}
|
||||
// 同一天内按紧急程度排序:紧急 > 重要 > 普通
|
||||
for (const date of Object.keys(map)) {
|
||||
map[date].sort(compareByPriority);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
@@ -487,6 +503,32 @@ const pendingCount = computed(
|
||||
() => selectedItems.value.filter(item => item.status !== 1).length
|
||||
);
|
||||
|
||||
// ---------- 多选(勾选后批量延期,区别于"完成"操作) ----------
|
||||
// 只记录 id,数据刷新后自动失效
|
||||
const selectedIds = ref([]);
|
||||
|
||||
// 已勾选且仍未完成的日程,只有这些才会被延期
|
||||
const selectedPendingIds = computed(() =>
|
||||
selectedIds.value.filter(id => {
|
||||
const item = selectedItems.value.find(i => i.id === id);
|
||||
return item && item.status !== 1;
|
||||
})
|
||||
);
|
||||
|
||||
function toggleSelect(id, checked) {
|
||||
const set = new Set(selectedIds.value);
|
||||
if (checked) {
|
||||
set.add(id);
|
||||
} else {
|
||||
set.delete(id);
|
||||
}
|
||||
selectedIds.value = [...set];
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = [];
|
||||
}
|
||||
|
||||
const selectedDateLabel = computed(() => {
|
||||
const [y, m, d] = selectedDate.value.split("-").map(Number);
|
||||
const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()];
|
||||
@@ -516,6 +558,8 @@ async function loadCalendarData() {
|
||||
...item,
|
||||
schedule_date: normDate(item.schedule_date)
|
||||
}));
|
||||
// 数据刷新后旧的勾选已失效(id 可能已因延期重新生成),统一清空
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function changeMonth(delta) {
|
||||
@@ -650,6 +694,8 @@ function handleDetailPostpone(item) {
|
||||
function handleItemCommand(cmd, item) {
|
||||
if (cmd === "postpone") {
|
||||
openPostpone(item);
|
||||
} else if (cmd === "finish") {
|
||||
toggleFinish(item);
|
||||
} else if (cmd === "edit") {
|
||||
openEdit(item);
|
||||
} else if (cmd === "delete") {
|
||||
@@ -670,23 +716,50 @@ function openPostpone(item) {
|
||||
postponeVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量延期:把选中日期的全部未完成日程一次性延期到下一天
|
||||
// 批量延期:优先延期勾选的日程;未勾选时延期选中日期的全部未完成日程
|
||||
async function handleCarryPending() {
|
||||
if (pendingCount.value === 0) {
|
||||
const ids = selectedPendingIds.value;
|
||||
const count = ids.length || pendingCount.value;
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
// 目标日期取"选定日期+1天",但不早于今天,避免把过去的任务再延到过去
|
||||
const nextDay = addDays(selectedDate.value, 1);
|
||||
const target = nextDay < todayStr() ? todayStr() : nextDay;
|
||||
const tip = ids.length
|
||||
? `将选中的 ${count} 条日程延期到 ${target}?当天这些日程会标记为已完成。`
|
||||
: `将 ${selectedDate.value} 的 ${count} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将 ${selectedDate.value} 的 ${pendingCount.value} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`,
|
||||
"批量延期",
|
||||
{ type: "warning", confirmButtonText: "延期", cancelButtonText: "取消" }
|
||||
);
|
||||
await ElMessageBox.confirm(tip, "批量延期", {
|
||||
type: "warning",
|
||||
confirmButtonText: "延期",
|
||||
cancelButtonText: "取消"
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// 有勾选:逐条调用单条延期接口(后端批量接口只支持按日期整批延期)
|
||||
if (ids.length) {
|
||||
let success = 0;
|
||||
for (const id of ids) {
|
||||
const res = await carrySchedule(id, { target_date: target });
|
||||
if (res?.code === 200) {
|
||||
success++;
|
||||
}
|
||||
}
|
||||
const skipped = ids.length - success;
|
||||
if (success) {
|
||||
ElMessage.success(
|
||||
`已延期 ${success} 条${skipped ? `,跳过 ${skipped} 条` : ""}`
|
||||
);
|
||||
} else {
|
||||
ElMessage.warning("所选日程均无法延期");
|
||||
}
|
||||
await reloadAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await carryPendingSchedules({
|
||||
date: selectedDate.value,
|
||||
target_date: target
|
||||
@@ -769,6 +842,25 @@ function priorityTagType(priority) {
|
||||
return { 1: "warning", 2: "danger" }[priority] || "info";
|
||||
}
|
||||
|
||||
// 排序权重:紧急(2) > 重要(1) > 普通(0)
|
||||
function priorityWeight(priority) {
|
||||
return { 2: 3, 1: 2 }[priority] || 1;
|
||||
}
|
||||
|
||||
// 同天内排序:先按紧急程度降序,再按开始时间升序(全天排在最前),最后按 id
|
||||
function compareByPriority(a, b) {
|
||||
const diff = priorityWeight(b.priority) - priorityWeight(a.priority);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
const ta = a.all_day === 1 ? "00:00" : a.start_time || "";
|
||||
const tb = b.all_day === 1 ? "00:00" : b.start_time || "";
|
||||
if (ta !== tb) {
|
||||
return ta < tb ? -1 : 1;
|
||||
}
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
loadCalendarData();
|
||||
@@ -1038,7 +1130,7 @@ onMounted(() => {
|
||||
|
||||
/* 选中日期侧栏 */
|
||||
.day-panel {
|
||||
width: 320px;
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
|
||||
Reference in New Issue
Block a user