OfficeSystem/pages/notice/create/index.vue
2025-11-19 15:28:32 +08:00

764 lines
18 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, onMounted } from 'vue';
import { createNotice, getUserList } from '@/api';
import AttachmentImageUploader from '@/components/task/AttachmentImageUploader.vue';
import AttachmentFileUploader from '@/components/task/AttachmentFileUploader.vue';
const formData = ref({
title: '',
type: '1',
level: '1',
top: false,
content: '',
images: [],
files: [],
receiveUsers: [],
receiveDepts: []
});
const submitting = ref(false);
const loadingUsers = 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 handleSubmit = async () => {
if (!canSubmit.value) {
uni.showToast({
title: '请完善必填信息',
icon: 'none'
});
return;
}
const payload = {
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 createNotice(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;
}
};
onMounted(() => {
loadUserOptions();
});
</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>