消息模块、爬虫

This commit is contained in:
2025-11-13 19:00:27 +08:00
parent 2982d53800
commit e20a7755f8
85 changed files with 8637 additions and 201 deletions

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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';