OfficeSystem/pages/notice/edit/index.vue
2025-11-22 14:46:25 +08:00

919 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="notice-create-page">
<view class="content-scroll" >
<view class="form-card">
<view class="section-title">公告信息</view>
<view class="form-item">
<text class="form-label required">标题</text>
<input
v-model.trim="formData.title"
class="text-input"
placeholder="请输入公告标题"
placeholder-style="color:#999;"
maxlength="50"
/>
</view>
<view class="form-item">
<text class="form-label required">类型</text>
<view class="pill-group">
<view
class="pill-item"
v-for="type in typeOptions"
:key="type.value"
:class="{ active: formData.type === type.value }"
@click="selectType(type.value)"
>
{{ type.label }}
</view>
</view>
</view>
<view class="form-item">
<text class="form-label required">重要程度</text>
<view class="pill-group">
<view
class="pill-item priority"
v-for="level in levelOptions"
:key="level.value"
:class="{ active: formData.level === level.value }"
@click="selectLevel(level.value)"
>
{{ level.label }}
</view>
</view>
</view>
<view class="form-item switch-item">
<text class="form-label required">置顶</text>
<uv-switch v-model="formData.top" activeColor="#2979ff" inactiveColor="#dcdfe6"></uv-switch>
</view>
<view class="form-item align-start">
<text class="form-label required">内容</text>
<textarea
v-model.trim="formData.content"
class="textarea-input"
placeholder="请输入公告内容最多1000字"
placeholder-style="color:#999;"
:maxlength="1000"
/>
</view>
</view>
<view class="form-card">
<view class="section-title">附件</view>
<AttachmentImageUploader v-model="formData.images" title="上传图片" icon="🖼️" />
<AttachmentFileUploader v-model="formData.files" title="上传文件" icon="📎" />
</view>
<view class="form-card">
<view class="section-title">接收对象</view>
<view class="form-item align-start">
<text class="form-label">接收用户</text>
<view class="selector-body">
<view class="chip-list" v-if="formData.receiveUsers.length">
<view
class="chip-item"
v-for="user in formData.receiveUsers"
:key="user.userId"
>
<text class="chip-name">{{ user.userName }}</text>
<text class="chip-remove" @click.stop="removeUser(user.userId)">✕</text>
</view>
</view>
<text v-else class="placeholder">请选择接收用户,可多选</text>
</view>
<view class="picker-trigger" @click="openUserModal">
<text>{{ formData.receiveUsers.length ? '调整' : '选择' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="form-item align-start">
<text class="form-label">接收部门</text>
<view class="selector-body">
<view class="chip-list" v-if="formData.receiveDepts.length">
<view
class="chip-item dept"
v-for="dept in formData.receiveDepts"
:key="dept.deptId"
>
<text class="chip-name">{{ dept.deptName }}</text>
<text class="chip-remove" @click.stop="removeDept(dept.deptId)">✕</text>
</view>
</view>
<text v-else class="placeholder">请选择接收部门,可多选</text>
</view>
<view class="picker-trigger" @click="openDeptModal">
<text>{{ formData.receiveDepts.length ? '调整' : '选择' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="receive-tip">
至少选择接收用户或接收部门中的任意一项。
</view>
</view>
</view>
<view class="submit-bar">
<uv-button
type="primary"
:disabled="!canSubmit || submitting"
:loading="submitting"
loadingText="提交中..."
@click="handleSubmit"
>
保存修改
</uv-button>
</view>
<!-- 选择接收用户弹窗 -->
<view class="selection-modal" v-if="showUserModal">
<view class="modal-mask" @click="closeUserModal"></view>
<view class="modal-panel">
<view class="modal-header">
<text class="modal-title">选择接收用户</text>
<text class="modal-close" @click="closeUserModal">✕</text>
</view>
<view class="search-box">
<input
v-model.trim="userKeyword"
class="search-input"
placeholder="搜索姓名或部门"
placeholder-style="color:#999;"
/>
</view>
<scroll-view class="options-list" scroll-y>
<view
class="option-item"
v-for="user in filteredUserOptions"
:key="user.userId"
@click="toggleUser(user.userId)"
>
<view class="option-info">
<text class="option-name">{{ user.userName }}</text>
<text class="option-desc" v-if="user.deptName">{{ user.deptName }}</text>
</view>
<view class="select-indicator" :class="{ active: selectedUserIds.includes(user.userId) }"></view>
</view>
<view class="empty-tip" v-if="!filteredUserOptions.length">
暂无可选人员
</view>
</scroll-view>
<view class="modal-actions">
<uv-button @click="closeUserModal">取消</uv-button>
<uv-button type="primary" @click="confirmUserSelection">确定</uv-button>
</view>
</view>
</view>
<!-- 选择接收部门弹窗 -->
<view class="selection-modal" v-if="showDeptModal">
<view class="modal-mask" @click="closeDeptModal"></view>
<view class="modal-panel">
<view class="modal-header">
<text class="modal-title">选择接收部门</text>
<text class="modal-close" @click="closeDeptModal">✕</text>
</view>
<view class="search-box">
<input
v-model.trim="deptKeyword"
class="search-input"
placeholder="搜索部门名称"
placeholder-style="color:#999;"
/>
</view>
<scroll-view class="options-list" scroll-y>
<view
class="option-item"
v-for="dept in filteredDeptOptions"
:key="dept.deptId"
@click="toggleDept(dept.deptId)"
>
<view class="option-info">
<text class="option-name">{{ dept.deptName }}</text>
</view>
<view class="select-indicator" :class="{ active: selectedDeptIds.includes(dept.deptId) }"></view>
</view>
<view class="empty-tip" v-if="!filteredDeptOptions.length">
暂无可选部门
</view>
</scroll-view>
<view class="modal-actions">
<uv-button @click="closeDeptModal">取消</uv-button>
<uv-button type="primary" @click="confirmDeptSelection">确定</uv-button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { getUserList, getNoticeDetail, updateNotice } from '@/api';
import { useUserStore } from '@/store/user';
import AttachmentImageUploader from '@/components/task/AttachmentImageUploader.vue';
import AttachmentFileUploader from '@/components/task/AttachmentFileUploader.vue';
const noticeId = ref('');
const formData = ref({
title: '',
type: '1',
level: '1',
top: false,
content: '',
images: [],
files: [],
receiveUsers: [],
receiveDepts: []
});
const submitting = ref(false);
const loadingUsers = ref(false);
const loadingDetail = ref(false);
const typeOptions = [
{ label: '通知', value: '1' },
{ label: '公告', value: '2' }
];
const levelOptions = [
{ label: '一般', value: '1' },
{ label: '重要', value: '2' },
{ label: '紧急', value: '3' }
];
const userOptions = ref([]);
const userKeyword = ref('');
const showUserModal = ref(false);
const selectedUserIds = ref([]);
const deptKeyword = ref('');
const showDeptModal = ref(false);
const selectedDeptIds = ref([]);
const deptOptions = computed(() => {
const map = new Map();
userOptions.value.forEach((user) => {
if (user.deptId && user.deptName && !map.has(user.deptId)) {
map.set(user.deptId, {
deptId: String(user.deptId),
deptName: user.deptName
});
}
});
return Array.from(map.values());
});
const filteredUserOptions = computed(() => {
if (!userKeyword.value.trim()) {
return userOptions.value;
}
const keyword = userKeyword.value.trim().toLowerCase();
return userOptions.value.filter((user) => {
const name = (user.userName || '').toLowerCase();
const dept = (user.deptName || '').toLowerCase();
return name.includes(keyword) || dept.includes(keyword);
});
});
const filteredDeptOptions = computed(() => {
if (!deptKeyword.value.trim()) {
return deptOptions.value;
}
const keyword = deptKeyword.value.trim().toLowerCase();
return deptOptions.value.filter((dept) => {
const name = (dept.deptName || '').toLowerCase();
return name.includes(keyword);
});
});
const canSubmit = computed(() => {
return Boolean(
formData.value.title.trim() &&
formData.value.content.trim() &&
formData.value.type &&
formData.value.level &&
(formData.value.receiveUsers.length > 0 || formData.value.receiveDepts.length > 0)
);
});
const selectType = (value) => {
formData.value.type = value;
};
const selectLevel = (value) => {
formData.value.level = value;
};
const openUserModal = () => {
selectedUserIds.value = formData.value.receiveUsers.map((item) => item.userId);
userKeyword.value = '';
showUserModal.value = true;
};
const closeUserModal = () => {
showUserModal.value = false;
};
const toggleUser = (userId) => {
const index = selectedUserIds.value.indexOf(userId);
if (index >= 0) {
selectedUserIds.value.splice(index, 1);
} else {
selectedUserIds.value.push(userId);
}
};
const confirmUserSelection = () => {
const selected = userOptions.value.filter((user) =>
selectedUserIds.value.includes(user.userId)
);
formData.value.receiveUsers = selected.map((user) => ({
userId: user.userId,
userName: user.userName,
deptName: user.deptName
}));
closeUserModal();
};
const removeUser = (userId) => {
formData.value.receiveUsers = formData.value.receiveUsers.filter(
(user) => user.userId !== userId
);
};
const openDeptModal = () => {
selectedDeptIds.value = formData.value.receiveDepts.map((item) => item.deptId);
deptKeyword.value = '';
showDeptModal.value = true;
};
const closeDeptModal = () => {
showDeptModal.value = false;
};
const toggleDept = (deptId) => {
const index = selectedDeptIds.value.indexOf(deptId);
if (index >= 0) {
selectedDeptIds.value.splice(index, 1);
} else {
selectedDeptIds.value.push(deptId);
}
};
const confirmDeptSelection = () => {
const selected = deptOptions.value.filter((dept) =>
selectedDeptIds.value.includes(dept.deptId)
);
formData.value.receiveDepts = selected.map((dept) => ({
deptId: dept.deptId,
deptName: dept.deptName
}));
closeDeptModal();
};
const removeDept = (deptId) => {
formData.value.receiveDepts = formData.value.receiveDepts.filter(
(dept) => dept.deptId !== deptId
);
};
const normalizeUser = (item) => ({
userId: String(item.userId || item.id),
userName: item.nickName || item.userName || '',
deptName: item.dept?.deptName || item.deptName || '',
deptId: String(item.dept?.deptId || item.deptId || '')
});
const loadUserOptions = async () => {
loadingUsers.value = true;
try {
const res = await getUserList({
pageNum: 1,
pageSize: 500,
status: 0,
delFlag: 0
});
const rows = Array.isArray(res?.rows) ? res.rows : Array.isArray(res?.data) ? res.data : [];
userOptions.value = rows.map((item) => normalizeUser(item)).filter((user) => user.userId);
} catch (error) {
console.error('加载用户列表失败:', error);
uni.showToast({
title: '加载用户列表失败',
icon: 'none'
});
} finally {
loadingUsers.value = false;
}
};
const buildAttachmentsPayload = () => {
const imageAttachments = formData.value.images.map((url) => ({
url,
name: url.split('/').pop() || '图片'
}));
const fileAttachments = formData.value.files.map((file) => ({
url: file.path,
name: file.name || '附件',
size: file.size || 0
}));
return [...imageAttachments, ...fileAttachments];
};
// 从公告详情中解析附件,填充到表单
const parseAttachmentsFromDetail = (attaches) => {
if (!attaches) {
formData.value.images = [];
formData.value.files = [];
return;
}
let list = [];
if (typeof attaches === 'string') {
try {
const parsed = JSON.parse(attaches);
if (Array.isArray(parsed)) {
list = parsed;
}
} catch (e) {
list = attaches
.split(',')
.map((url) => url.trim())
.filter((url) => url)
.map((url) => ({
url,
name: url.split('/').pop() || '附件'
}));
}
} else if (Array.isArray(attaches)) {
list = attaches;
}
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const images = [];
const files = [];
list.forEach((attach) => {
const url = attach.url || attach;
const name = attach.name || attach;
const ext = (url || name).split('.').pop()?.toLowerCase();
if (ext && imageExts.includes(ext)) {
images.push(url);
} else {
files.push({
path: url,
name: name || '附件',
size: attach.size || 0
});
}
});
formData.value.images = images;
formData.value.files = files;
};
const loadNoticeDetail = async () => {
if (!noticeId.value) return;
loadingDetail.value = true;
try {
const res = await getNoticeDetail(noticeId.value);
if (!res) return;
formData.value.title = res.title || '';
formData.value.type = res.type || '1';
formData.value.level = res.level || '1';
formData.value.top = !!res.top;
formData.value.content = res.content || '';
// 接收用户
if (Array.isArray(res.receiveUserList) && res.receiveUserList.length) {
formData.value.receiveUsers = res.receiveUserList.map((user) => ({
userId: String(user.userId || user.id),
userName: user.nickName || user.userName || '',
deptName: user.deptName || user.dept?.deptName || ''
}));
} else if (Array.isArray(res.receiveUserIds) && res.receiveUserIds.length && userOptions.value.length) {
const idSet = new Set(res.receiveUserIds.map((id) => String(id)));
formData.value.receiveUsers = userOptions.value
.filter((user) => idSet.has(String(user.userId)))
.map((user) => ({
userId: user.userId,
userName: user.userName,
deptName: user.deptName
}));
}
// 接收部门
if (Array.isArray(res.receiveDeptList) && res.receiveDeptList.length) {
formData.value.receiveDepts = res.receiveDeptList.map((dept) => ({
deptId: String(dept.deptId),
deptName: dept.deptName || ''
}));
} else if (Array.isArray(res.receiveDeptIds) && res.receiveDeptIds.length) {
const idSet = new Set(res.receiveDeptIds.map((id) => String(id)));
formData.value.receiveDepts = deptOptions.value.filter((dept) =>
idSet.has(String(dept.deptId))
);
}
parseAttachmentsFromDetail(res.attaches);
} catch (error) {
console.error('加载公告详情失败:', error);
uni.showToast({
title: '加载公告详情失败',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} finally {
loadingDetail.value = false;
}
};
const handleSubmit = async () => {
if (!canSubmit.value) {
uni.showToast({
title: '请完善必填信息',
icon: 'none'
});
return;
}
const payload = {
id: noticeId.value,
title: formData.value.title.trim(),
type: formData.value.type,
level: formData.value.level,
top: formData.value.top,
content: formData.value.content.trim(),
receiveUserIds: formData.value.receiveUsers.map((user) => user.userId),
receiveDeptIds: formData.value.receiveDepts.map((dept) => dept.deptId),
attaches: JSON.stringify(buildAttachmentsPayload())
};
submitting.value = true;
try {
await updateNotice(payload);
uni.showToast({
title: '修改成功',
icon: 'success'
});
uni.$emit('notice:updated');
setTimeout(() => {
uni.navigateBack();
}, 800);
} catch (error) {
console.error('修改公告失败:', error);
uni.showToast({
title: error?.message || '修改失败',
icon: 'none'
});
} finally {
submitting.value = false;
}
};
// 用户角色判断
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const isAdmin = computed(() => {
if (!userInfo.value || !Array.isArray(userInfo.value.roles)) return false;
return userInfo.value.roles.some((role) => ['admin', 'sys_admin'].includes(role));
});
onLoad(async (options) => {
// 权限检查:只有 admin 和 sys_admin 可以编辑公告
if (!isAdmin.value) {
uni.showToast({
title: '无权限访问',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
return;
}
if (!options || !options.id) {
uni.showToast({
title: '缺少公告ID',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
return;
}
noticeId.value = options.id;
await loadUserOptions();
await loadNoticeDetail();
});
</script>
<style lang="scss" scoped>
.notice-create-page {
width: 100%;
height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.content-scroll {
flex: 1;
width: 100%;
}
.form-card {
background: #fff;
margin: 12px;
margin-bottom: 0;
padding: 16px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
&:last-of-type {
margin-bottom: 12px;
}
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #333;
}
.form-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
width: 80px;
font-size: 14px;
color: #333;
flex-shrink: 0;
&.required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
.text-input {
flex: 1;
background: #f7f8fa;
border-radius: 8px;
padding: 12px;
font-size: 14px;
}
.pill-group {
flex: 1;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill-item {
padding: 8px 16px;
border-radius: 999px;
border: 1px solid #dcdfe6;
font-size: 13px;
color: #666;
&.active {
border-color: #2979ff;
color: #2979ff;
background: rgba(41, 121, 255, 0.08);
}
}
.pill-item.priority {
&.active {
background: rgba(255, 152, 0, 0.12);
border-color: #ff9800;
color: #ff9800;
}
}
.switch-item {
justify-content: space-between;
}
.align-start {
align-items: flex-start;
}
.textarea-input {
flex: 1;
min-height: 120px;
background: #f7f8fa;
border-radius: 12px;
padding: 12px;
font-size: 14px;
line-height: 1.6;
}
.selector-body {
flex: 1;
min-height: 44px;
background: #f7f8fa;
border-radius: 12px;
padding: 10px;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(41, 121, 255, 0.12);
color: #2979ff;
font-size: 12px;
&.dept {
background: rgba(56, 182, 73, 0.12);
color: #38b649;
}
}
.chip-remove {
font-size: 12px;
color: inherit;
}
.picker-trigger {
display: flex;
align-items: center;
gap: 4px;
color: #2979ff;
font-size: 14px;
}
.arrow {
font-size: 18px;
color: #999;
}
.placeholder {
font-size: 13px;
color: #999;
}
.receive-tip {
font-size: 12px;
color: #999;
margin-top: -4px;
}
.submit-bar {
padding: 12px;
background: #fff;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.03);
}
.selection-modal {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-mask {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
}
.modal-panel {
position: relative;
width: 100%;
background: #fff;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
}
.modal-close {
font-size: 18px;
color: #999;
}
.search-box {
margin-bottom: 12px;
}
.search-input {
width: 100%;
padding: 12px;
border-radius: 10px;
background: #f7f8fa;
font-size: 14px;
}
.options-list {
height: 600px;
}
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.option-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.option-name {
font-size: 15px;
color: #333;
}
.option-desc {
font-size: 13px;
color: #999;
}
.select-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #dcdfe6;
&.active {
background: #2979ff;
border-color: #2979ff;
}
}
.empty-tip {
text-align: center;
color: #999;
padding: 20px 0;
font-size: 13px;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
</style>