优化日程增加文件上传功能

This commit is contained in:
2026-09-09 09:55:05 +08:00
parent 04fc84dde6
commit d2643a3697
7 changed files with 513 additions and 15 deletions
+9
View File
@@ -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="请输入日程标题"
/>
+103 -11
View File
@@ -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;
+99
View File
@@ -299,6 +299,51 @@ type oaSchedulePayload struct {
Priority int8 `json:"priority"`
}
// oaScheduleImage 日程相关图片(截图/照片),只存文件引用,不存二进制。
type oaScheduleImage struct {
ID uint64 `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
}
const (
oaScheduleMaxImages = 20
oaScheduleMaxImageURL = 500
oaScheduleMaxImageName = 120
)
// normalizeOaScheduleImages 校验并序列化日程图片列表:
// 丢弃无 url 或超长的条目,条目数超限时返回 false。
func normalizeOaScheduleImages(list []oaScheduleImage) (string, bool) {
if len(list) > oaScheduleMaxImages {
return "", false
}
out := make([]oaScheduleImage, 0, len(list))
for _, img := range list {
img.URL = strings.TrimSpace(img.URL)
if img.URL == "" || len(img.URL) > oaScheduleMaxImageURL {
continue
}
img.Name = strings.TrimSpace(img.Name)
if utf8.RuneCountInString(img.Name) > oaScheduleMaxImageName {
img.Name = string([]rune(img.Name)[:oaScheduleMaxImageName])
}
out = append(out, img)
}
raw, err := json.Marshal(out)
if err != nil {
return "", false
}
return string(raw), true
}
// parseOaScheduleImages 解析 images 列;脏数据返回空数组,不阻断详情展示。
func parseOaScheduleImages(raw string) []oaScheduleImage {
list := make([]oaScheduleImage, 0)
_ = json.Unmarshal([]byte(raw), &list)
return list
}
// parseOaSchedulePayload 读取并校验新建/编辑日程的请求体;校验失败时直接输出错误响应。
func (c *BackendOaScheduleController) parseOaSchedulePayload() (oaSchedulePayload, bool) {
var payload oaSchedulePayload
@@ -442,6 +487,59 @@ func (c *BackendOaScheduleController) Update() {
c.oasOk(item)
}
// SetImages POST /backend/oa/schedule/images/:id
// 单独维护日程的相关图片(文件已先上传到文件管理,这里只存引用)。
// 与 Update 分开,避免编辑标题/时间等基础信息时把图片清空。
func (c *BackendOaScheduleController) SetImages() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
var payload struct {
Images []oaScheduleImage `json:"images"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 && json.Unmarshal(raw, &payload) != nil {
c.oasJsonErr(400, 400, "参数错误")
return
}
imagesJSON, ok := normalizeOaScheduleImages(payload.Images)
if !ok {
c.oasJsonErr(400, 400, "图片数量过多")
return
}
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
now := time.Now()
item.Images = imagesJSON
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "Images", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
c.oasOk(map[string]interface{}{"images": parseOaScheduleImages(imagesJSON)})
}
// Delete DELETE /backend/oa/schedule/delete/:id 软删除日程。
func (c *BackendOaScheduleController) Delete() {
claims, err := c.oaScheduleClaims()
@@ -577,6 +675,7 @@ func carryOaSchedule(item *models.OaSchedule, targetDate string, claims *jwtutil
UserName: item.UserName,
Title: item.Title,
Content: item.Content,
Images: item.Images,
ScheduleDate: targetDate,
StartTime: item.StartTime,
EndTime: item.EndTime,
+4 -1
View File
@@ -21,6 +21,8 @@ type OaSchedule struct {
UserName string `orm:"column(user_name);size(100);default()" json:"user_name"`
Title string `orm:"column(title);size(255)" json:"title"`
Content string `orm:"column(content);type(text);null" json:"content"`
// Images 相关图片(截图/照片),存 JSON 数组:[{"id":1,"url":"","name":""}]
Images string `orm:"column(images);type(text);null" json:"images"`
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
StartTime string `orm:"column(start_time);size(8);default()" json:"start_time"`
EndTime string `orm:"column(end_time);size(8);default()" json:"end_time"`
@@ -45,7 +47,7 @@ func (m *OaSchedule) TableName() string {
var oaScheduleCarryColumnsOnce sync.Once
// EnsureOaScheduleCarryColumns 为日程表补齐"顺延到下一天"所需字段。
// EnsureOaScheduleCarryColumns 为日程表补齐"顺延到下一天"与"相关图片"所需字段。
// 历史库可能早于该特性建表,这里在首次访问日程接口时自动补列,
// 字段已存在时 MySQL 会报 duplicate column,统一忽略,不阻断主流程。
func EnsureOaScheduleCarryColumns() error {
@@ -59,6 +61,7 @@ func EnsureOaScheduleCarryColumns() error {
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_over tinyint NOT NULL DEFAULT 0 COMMENT '是否已顺延到下一天 0-否 1-是'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_count int NOT NULL DEFAULT 0 COMMENT '累计顺延次数'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_to_date varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN images text NULL COMMENT '相关图片JSON数组 [{id,url,name}]'",
}
for _, sql := range alters {
if _, e := Orm.Raw(sql).Exec(); e != nil {
+1
View File
@@ -280,6 +280,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/schedule/finish/:id", &controllers.BackendOaScheduleController{}, "post:FinishSchedule")
beego.Router("/backend/oa/schedule/carry/:id", &controllers.BackendOaScheduleController{}, "post:CarrySchedule")
beego.Router("/backend/oa/schedule/carry-pending", &controllers.BackendOaScheduleController{}, "post:CarryPending")
beego.Router("/backend/oa/schedule/images/:id", &controllers.BackendOaScheduleController{}, "post:SetImages")
// OA通知公告
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")