消息模块、爬虫
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<span class="message-priority-badge" :class="`priority-${priority}`">
|
||||
{{ priorityText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/** 优先级:urgent-紧急/important-重要/normal-普通 */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
/** 优先级文本映射 */
|
||||
const priorityText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
urgent: '紧急',
|
||||
important: '重要',
|
||||
normal: '普通'
|
||||
};
|
||||
return map[props.priority] || props.priority;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
|
||||
&.priority-urgent {
|
||||
background-color: #fee;
|
||||
color: #c00;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
&.priority-important {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
border: 1px solid #ffcc80;
|
||||
}
|
||||
|
||||
&.priority-normal {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #a5d6a7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="message-send-method-selector">
|
||||
<div class="method-option">
|
||||
<label class="method-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="system"
|
||||
:checked="selectedMethods.includes('system')"
|
||||
@change="toggleMethod('system')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<span class="method-text">
|
||||
<i class="el-icon-message"></i>
|
||||
系统消息
|
||||
</span>
|
||||
</label>
|
||||
<span class="method-desc">在系统内推送消息通知</span>
|
||||
</div>
|
||||
|
||||
<div class="method-option">
|
||||
<label class="method-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="email"
|
||||
:checked="selectedMethods.includes('email')"
|
||||
@change="toggleMethod('email')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<span class="method-text">
|
||||
<i class="el-icon-message-solid"></i>
|
||||
邮件通知
|
||||
</span>
|
||||
</label>
|
||||
<span class="method-desc">发送到用户邮箱</span>
|
||||
</div>
|
||||
|
||||
<div class="method-option">
|
||||
<label class="method-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="sms"
|
||||
:checked="selectedMethods.includes('sms')"
|
||||
@change="toggleMethod('sms')"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<span class="method-text">
|
||||
<i class="el-icon-phone"></i>
|
||||
短信通知
|
||||
</span>
|
||||
</label>
|
||||
<span class="method-desc">发送到用户手机</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-tip">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/** 已选择的发送方式列表 */
|
||||
modelValue: string[];
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否必须至少选择一个 */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string[]): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
required: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedMethods = computed(() => props.modelValue);
|
||||
|
||||
const error = computed(() => {
|
||||
if (props.required && selectedMethods.value.length === 0) {
|
||||
return '请至少选择一种发送方式';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
/** 切换发送方式 */
|
||||
function toggleMethod(method: string) {
|
||||
if (props.disabled) return;
|
||||
|
||||
const methods = [...selectedMethods.value];
|
||||
const index = methods.indexOf(method);
|
||||
|
||||
if (index > -1) {
|
||||
// 如果是必选且只剩一个,不允许取消
|
||||
if (props.required && methods.length === 1) {
|
||||
return;
|
||||
}
|
||||
methods.splice(index, 1);
|
||||
} else {
|
||||
methods.push(method);
|
||||
}
|
||||
|
||||
emit('update:modelValue', methods);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-send-method-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.method-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
border-color: #c8232c;
|
||||
}
|
||||
|
||||
.method-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.method-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.method-desc {
|
||||
margin-left: 24px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.error-tip {
|
||||
font-size: 12px;
|
||||
color: #c00;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
schoolNewsWeb/src/components/message/MessageStatusBadge.vue
Normal file
76
schoolNewsWeb/src/components/message/MessageStatusBadge.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<span class="message-status-badge" :class="`status-${status}`">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/** 消息状态:draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-发送失败/cancelled-已取消 */
|
||||
status: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
/** 状态文本映射 */
|
||||
const statusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
pending: '待发送',
|
||||
sending: '发送中',
|
||||
sent: '已发送',
|
||||
failed: '发送失败',
|
||||
cancelled: '已取消'
|
||||
};
|
||||
return map[props.status] || props.status;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.status-draft {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
border: 1px solid #ffcc80;
|
||||
}
|
||||
|
||||
&.status-sending {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #90caf9;
|
||||
}
|
||||
|
||||
&.status-sent {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #a5d6a7;
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
background-color: #fee;
|
||||
color: #c00;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
&.status-cancelled {
|
||||
background-color: #fafafa;
|
||||
color: #999;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
649
schoolNewsWeb/src/components/message/MessageTargetSelector.vue
Normal file
649
schoolNewsWeb/src/components/message/MessageTargetSelector.vue
Normal file
@@ -0,0 +1,649 @@
|
||||
<template>
|
||||
<div class="message-target-selector">
|
||||
<!-- Tab切换 -->
|
||||
<div class="target-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="switchTab(tab.key)"
|
||||
>
|
||||
<i :class="tab.icon"></i>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab内容 -->
|
||||
<div class="target-content">
|
||||
<!-- 部门选择 -->
|
||||
<div v-if="activeTab === 'dept'" class="target-panel">
|
||||
<div class="panel-header">
|
||||
<span>已选择 {{ selectedDepts.length }} 个部门</span>
|
||||
<button class="btn-select" @click="showDeptSelector">
|
||||
<i class="el-icon-plus"></i> 选择部门
|
||||
</button>
|
||||
</div>
|
||||
<div class="selected-list">
|
||||
<div
|
||||
v-for="item in selectedDepts"
|
||||
:key="item.id"
|
||||
class="selected-item"
|
||||
>
|
||||
<i class="el-icon-office-building"></i>
|
||||
<span>{{ item.name }}</span>
|
||||
<i class="el-icon-close remove-btn" @click="removeDept(item.id)"></i>
|
||||
</div>
|
||||
<div v-if="selectedDepts.length === 0" class="empty-tip">
|
||||
请选择部门
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色选择 -->
|
||||
<div v-if="activeTab === 'role'" class="target-panel">
|
||||
<div class="scope-selector">
|
||||
<label>作用域部门:</label>
|
||||
<select v-model="roleScopeDeptID" class="dept-select" @change="onRoleScopeChange">
|
||||
<option value="">请选择部门范围</option>
|
||||
<option v-for="dept in availableDepts" :key="dept.id" :value="dept.id">
|
||||
{{ dept.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="tip">(限制该角色只能向此部门及子部门的用户发送)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="roleScopeDeptID" class="panel-header">
|
||||
<span>已选择 {{ selectedRoles.length }} 个角色</span>
|
||||
<button class="btn-select" @click="showRoleSelector">
|
||||
<i class="el-icon-plus"></i> 选择角色
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="roleScopeDeptID" class="selected-list">
|
||||
<div
|
||||
v-for="item in selectedRoles"
|
||||
:key="item.id"
|
||||
class="selected-item"
|
||||
>
|
||||
<i class="el-icon-user"></i>
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="item-sublabel">{{ item.deptName }}</span>
|
||||
<i class="el-icon-close remove-btn" @click="removeRole(item.id)"></i>
|
||||
</div>
|
||||
<div v-if="selectedRoles.length === 0" class="empty-tip">
|
||||
请选择角色
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户选择 -->
|
||||
<div v-if="activeTab === 'user'" class="target-panel">
|
||||
<div class="scope-selector">
|
||||
<label>作用域部门:</label>
|
||||
<select v-model="userScopeDeptID" class="dept-select" @change="onUserScopeChange">
|
||||
<option value="">请选择部门范围</option>
|
||||
<option v-for="dept in availableDepts" :key="dept.id" :value="dept.id">
|
||||
{{ dept.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="tip">(只能选择此部门及子部门的用户)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="userScopeDeptID" class="panel-header">
|
||||
<span>已选择 {{ selectedUsers.length }} 个用户</span>
|
||||
<button class="btn-select" @click="showUserSelector">
|
||||
<i class="el-icon-plus"></i> 选择用户
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="userScopeDeptID" class="selected-list">
|
||||
<div
|
||||
v-for="item in selectedUsers"
|
||||
:key="item.id"
|
||||
class="selected-item"
|
||||
>
|
||||
<i class="el-icon-user-solid"></i>
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="item-sublabel">{{ item.deptName }}</span>
|
||||
<i class="el-icon-close remove-btn" @click="removeUser(item.id)"></i>
|
||||
</div>
|
||||
<div v-if="selectedUsers.length === 0" class="empty-tip">
|
||||
请选择用户
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用选择器弹窗 -->
|
||||
<GenericSelector
|
||||
v-model:visible="selectorVisible"
|
||||
:title="selectorTitle"
|
||||
:fetch-available-api="fetchAvailableApi"
|
||||
:fetch-selected-api="fetchSelectedApi"
|
||||
:filter-selected="filterSelected"
|
||||
:item-config="itemConfig"
|
||||
:unit-name="unitName"
|
||||
@confirm="handleSelectorConfirm"
|
||||
@cancel="selectorVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
import type { TbSysMessageTarget, ResultDomain } from '@/types';
|
||||
import { deptApi, roleApi, userApi } from '@/apis/system';
|
||||
|
||||
// 本地类型定义
|
||||
interface TargetOption {
|
||||
id: string;
|
||||
name: string;
|
||||
deptID?: string;
|
||||
deptName?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: TbSysMessageTarget[]): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 已选择的目标配置列表 */
|
||||
modelValue: TbSysMessageTarget[];
|
||||
/** 发送方式(用于生成TbSysMessageTarget) */
|
||||
sendMethod: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
/** Tab配置 */
|
||||
const tabs = [
|
||||
{ key: 'dept', label: '按部门', icon: 'el-icon-office-building' },
|
||||
{ key: 'role', label: '按角色', icon: 'el-icon-user' },
|
||||
{ key: 'user', label: '按用户', icon: 'el-icon-user-solid' }
|
||||
] as const;
|
||||
|
||||
const activeTab = ref<'dept' | 'role' | 'user'>('dept');
|
||||
|
||||
// 可选的部门列表(当前部门及子部门)
|
||||
const availableDepts = ref<TargetOption[]>([]);
|
||||
|
||||
// 已选择的目标
|
||||
const selectedDepts = ref<TargetOption[]>([]);
|
||||
const selectedRoles = ref<TargetOption[]>([]);
|
||||
const selectedUsers = ref<TargetOption[]>([]);
|
||||
|
||||
// 作用域部门ID
|
||||
const roleScopeDeptID = ref('');
|
||||
const userScopeDeptID = ref('');
|
||||
|
||||
// 选择器状态
|
||||
const selectorVisible = ref(false);
|
||||
const selectorType = ref<'dept' | 'role' | 'user'>('dept');
|
||||
|
||||
/** 选择器标题 */
|
||||
const selectorTitle = computed(() => {
|
||||
const map = {
|
||||
dept: '选择部门',
|
||||
role: '选择角色',
|
||||
user: '选择用户'
|
||||
};
|
||||
return map[selectorType.value];
|
||||
});
|
||||
|
||||
/** 选择器单位名称 */
|
||||
const unitName = computed(() => {
|
||||
const map = {
|
||||
dept: '个部门',
|
||||
role: '个角色',
|
||||
user: '个用户'
|
||||
};
|
||||
return map[selectorType.value];
|
||||
});
|
||||
|
||||
/** 选择器字段配置 */
|
||||
const itemConfig = computed(() => ({
|
||||
id: 'id',
|
||||
label: 'name',
|
||||
sublabel: selectorType.value !== 'dept' ? 'deptName' : undefined
|
||||
}));
|
||||
|
||||
/** 获取可选项API */
|
||||
const fetchAvailableApi = computed(() => {
|
||||
return async () => {
|
||||
if (selectorType.value === 'dept') {
|
||||
// 调用 system 模块的部门 API
|
||||
const result = await deptApi.getAllDepts();
|
||||
// 转换数据格式为 GenericSelector 需要的格式
|
||||
if (result.success && result.dataList) {
|
||||
return {
|
||||
...result,
|
||||
dataList: result.dataList.map((dept: any) => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
deptID: dept.id,
|
||||
deptName: dept.name
|
||||
}))
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} else if (selectorType.value === 'role') {
|
||||
// 调用 system 模块的角色 API,根据部门过滤
|
||||
const filter = roleScopeDeptID.value ? { deptID: roleScopeDeptID.value } : {};
|
||||
const result = await roleApi.getRoleList(filter as any);
|
||||
// 转换数据格式
|
||||
if (result.success && result.dataList) {
|
||||
return {
|
||||
...result,
|
||||
dataList: result.dataList.map((role: any) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
deptID: role.deptID,
|
||||
deptName: role.deptName
|
||||
}))
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// 调用 system 模块的用户 API,根据部门过滤
|
||||
const filter = userScopeDeptID.value ? { deptID: userScopeDeptID.value } : {};
|
||||
const result = await userApi.getUserList(filter as any);
|
||||
// 转换数据格式
|
||||
if (result.success && result.dataList) {
|
||||
return {
|
||||
...result,
|
||||
dataList: result.dataList.map((user: any) => ({
|
||||
id: user.id,
|
||||
name: user.realName || user.username,
|
||||
deptID: user.deptID,
|
||||
deptName: user.deptName
|
||||
}))
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** 获取已选项API(返回当前已选择的项) */
|
||||
const fetchSelectedApi = computed(() => {
|
||||
return async (): Promise<ResultDomain<TargetOption>> => {
|
||||
let dataList: TargetOption[] = [];
|
||||
if (selectorType.value === 'dept') {
|
||||
dataList = selectedDepts.value;
|
||||
} else if (selectorType.value === 'role') {
|
||||
dataList = selectedRoles.value;
|
||||
} else {
|
||||
dataList = selectedUsers.value;
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
login: true,
|
||||
auth: true,
|
||||
dataList
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
/** 过滤已选项(从可选项中移除已选项) */
|
||||
const filterSelected = (available: Record<string, any>[], selected: Record<string, any>[]): Record<string, any>[] => {
|
||||
const availableOptions = available as TargetOption[];
|
||||
const selectedOptions = selected as TargetOption[];
|
||||
const selectedIds = new Set(selectedOptions.map(item => item.id));
|
||||
return availableOptions.filter(item => !selectedIds.has(item.id));
|
||||
};
|
||||
|
||||
/** 加载可用部门列表 */
|
||||
async function loadAvailableDepts() {
|
||||
try {
|
||||
const result = await deptApi.getAllDepts();
|
||||
if (result.success && result.dataList) {
|
||||
// 转换数据格式
|
||||
availableDepts.value = result.dataList.map((dept: any) => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
deptID: dept.id,
|
||||
deptName: dept.name
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换Tab */
|
||||
function switchTab(tab: 'dept' | 'role' | 'user') {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
/** 显示部门选择器 */
|
||||
function showDeptSelector() {
|
||||
selectorType.value = 'dept';
|
||||
selectorVisible.value = true;
|
||||
}
|
||||
|
||||
/** 显示角色选择器 */
|
||||
function showRoleSelector() {
|
||||
if (!roleScopeDeptID.value) {
|
||||
alert('请先选择作用域部门');
|
||||
return;
|
||||
}
|
||||
selectorType.value = 'role';
|
||||
selectorVisible.value = true;
|
||||
}
|
||||
|
||||
/** 显示用户选择器 */
|
||||
function showUserSelector() {
|
||||
if (!userScopeDeptID.value) {
|
||||
alert('请先选择作用域部门');
|
||||
return;
|
||||
}
|
||||
selectorType.value = 'user';
|
||||
selectorVisible.value = true;
|
||||
}
|
||||
|
||||
/** 选择器确认回调 */
|
||||
function handleSelectorConfirm(items: Record<string, any>[]) {
|
||||
const typedItems = items as TargetOption[];
|
||||
|
||||
if (selectorType.value === 'dept') {
|
||||
selectedDepts.value = typedItems;
|
||||
} else if (selectorType.value === 'role') {
|
||||
selectedRoles.value = typedItems;
|
||||
} else {
|
||||
selectedUsers.value = typedItems;
|
||||
}
|
||||
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 移除部门 */
|
||||
function removeDept(id: string) {
|
||||
selectedDepts.value = selectedDepts.value.filter(item => item.id !== id);
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 移除角色 */
|
||||
function removeRole(id: string) {
|
||||
selectedRoles.value = selectedRoles.value.filter(item => item.id !== id);
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 移除用户 */
|
||||
function removeUser(id: string) {
|
||||
selectedUsers.value = selectedUsers.value.filter(item => item.id !== id);
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 角色作用域变更 */
|
||||
function onRoleScopeChange() {
|
||||
// 清空已选择的角色
|
||||
selectedRoles.value = [];
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 用户作用域变更 */
|
||||
function onUserScopeChange() {
|
||||
// 清空已选择的用户
|
||||
selectedUsers.value = [];
|
||||
emitTargets();
|
||||
}
|
||||
|
||||
/** 发射目标配置 */
|
||||
function emitTargets() {
|
||||
const targets: TbSysMessageTarget[] = [];
|
||||
|
||||
// 添加部门目标
|
||||
selectedDepts.value.forEach(dept => {
|
||||
targets.push({
|
||||
sendMethod: props.sendMethod,
|
||||
targetType: 'dept',
|
||||
targetID: dept.id,
|
||||
targetName: dept.name,
|
||||
scopeDeptID: dept.id // 部门的作用域就是自己
|
||||
});
|
||||
});
|
||||
|
||||
// 添加角色目标
|
||||
selectedRoles.value.forEach(role => {
|
||||
targets.push({
|
||||
sendMethod: props.sendMethod,
|
||||
targetType: 'role',
|
||||
targetID: role.id,
|
||||
targetName: role.name,
|
||||
scopeDeptID: roleScopeDeptID.value
|
||||
});
|
||||
});
|
||||
|
||||
// 添加用户目标
|
||||
selectedUsers.value.forEach(user => {
|
||||
targets.push({
|
||||
sendMethod: props.sendMethod,
|
||||
targetType: 'user',
|
||||
targetID: user.id,
|
||||
targetName: user.name,
|
||||
scopeDeptID: userScopeDeptID.value
|
||||
});
|
||||
});
|
||||
|
||||
emit('update:modelValue', targets);
|
||||
}
|
||||
|
||||
/** 初始化已选项(从modelValue恢复) */
|
||||
function initSelectedTargets() {
|
||||
const targets = props.modelValue || [];
|
||||
|
||||
selectedDepts.value = [];
|
||||
selectedRoles.value = [];
|
||||
selectedUsers.value = [];
|
||||
|
||||
targets.forEach(target => {
|
||||
const option: TargetOption = {
|
||||
id: target.targetID!,
|
||||
name: '', // 名称需要从后端查询,暂时为空
|
||||
deptID: target.scopeDeptID,
|
||||
deptName: ''
|
||||
};
|
||||
|
||||
if (target.targetType === 'dept') {
|
||||
selectedDepts.value.push(option);
|
||||
} else if (target.targetType === 'role') {
|
||||
if (target.scopeDeptID) {
|
||||
roleScopeDeptID.value = target.scopeDeptID;
|
||||
}
|
||||
selectedRoles.value.push(option);
|
||||
} else if (target.targetType === 'user') {
|
||||
if (target.scopeDeptID) {
|
||||
userScopeDeptID.value = target.scopeDeptID;
|
||||
}
|
||||
selectedUsers.value.push(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 监听sendMethod变化,更新所有目标的sendMethod */
|
||||
watch(() => props.sendMethod, () => {
|
||||
emitTargets();
|
||||
});
|
||||
|
||||
/** 监听modelValue变化,同步到内部状态 */
|
||||
watch(() => props.modelValue, () => {
|
||||
initSelectedTargets();
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
loadAvailableDepts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-target-selector {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.target-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #c8232c;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid #c8232c;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.target-content {
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
|
||||
.target-panel {
|
||||
.scope-selector {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dept-select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #c8232c;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-select {
|
||||
padding: 6px 12px;
|
||||
background-color: #c8232c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #a01d24;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-height: 100px;
|
||||
|
||||
.selected-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-sublabel {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #c00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
schoolNewsWeb/src/components/message/index.ts
Normal file
10
schoolNewsWeb/src/components/message/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @description 消息通知组件导出
|
||||
* @author Claude
|
||||
* @since 2025-11-13
|
||||
*/
|
||||
|
||||
export { default as MessageTargetSelector } from './MessageTargetSelector.vue';
|
||||
export { default as MessageSendMethodSelector } from './MessageSendMethodSelector.vue';
|
||||
export { default as MessagePriorityBadge } from './MessagePriorityBadge.vue';
|
||||
export { default as MessageStatusBadge } from './MessageStatusBadge.vue';
|
||||
Reference in New Issue
Block a user