更新角色管理相关内容

This commit is contained in:
2026-09-09 15:04:34 +08:00
parent de7225b61e
commit fef0233cc4
24 changed files with 1442 additions and 116 deletions
@@ -278,4 +278,50 @@ onMounted(() => {
}
}
}
// ===== 暗色主题适配 =====
html.dark & {
.template-layout {
background-color: var(--el-bg-color-page);
}
.category-sidebar {
background: var(--el-bg-color);
border-right-color: var(--el-border-color);
.sidebar-title {
color: var(--el-text-color-primary);
}
}
.content-area {
.result-count {
color: var(--el-text-color-regular);
}
.loading-state {
color: var(--el-text-color-secondary);
}
.template-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.card-preview {
background: var(--el-fill-color-light);
}
.card-info {
.title {
color: var(--el-text-color-primary);
}
.description,
.meta {
color: var(--el-text-color-secondary);
}
}
}
}
}
</style>
+45 -4
View File
@@ -111,6 +111,10 @@ const getMonthLabels = (): string[] => {
const initChart = (refEl: HTMLElement, title: string, color: string) => {
const chart = echarts.init(refEl);
const months = getMonthLabels();
const isDark = document.documentElement.classList.contains('dark');
const palette = isDark
? { axis: '#4c4d4f', label: '#a8abb2', split: '#2b2b2d' }
: { axis: '#E0E6ED', label: '#606266', split: '#F2F6FC' };
chart.setOption({
tooltip: {
@@ -128,15 +132,15 @@ const initChart = (refEl: HTMLElement, title: string, color: string) => {
type: 'category',
boundaryGap: false,
data: months,
axisLine: { lineStyle: { color: '#E0E6ED' } },
axisLabel: { color: '#606266', fontSize: 11 }
axisLine: { lineStyle: { color: palette.axis } },
axisLabel: { color: palette.label, fontSize: 11 }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#F2F6FC' } },
axisLabel: { color: '#606266', fontSize: 11 }
splitLine: { lineStyle: { color: palette.split } },
axisLabel: { color: palette.label, fontSize: 11 }
},
series: [
{
@@ -386,4 +390,41 @@ onUnmounted(() => {
height: 280px;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.erp-dashboard {
background-color: var(--el-bg-color-page);
}
.stat-card {
background: var(--el-bg-color);
.stat-header {
.stat-label {
color: var(--el-text-color-regular);
}
.stat-icon {
color: var(--el-text-color-secondary);
}
}
.stat-value {
.currency,
.number {
color: var(--el-text-color-primary);
}
}
}
.chart-card {
background: var(--el-bg-color);
.chart-title {
color: var(--el-text-color-primary);
border-bottom-color: var(--el-border-color-lighter);
}
}
}
</style>
@@ -434,7 +434,7 @@ async function removeScheme(row) {
onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadDashboard(), loadPayrolls()]) })
</script>
<style scoped>
<style lang="less" scoped>
.compensation-page { padding: 16px 20px 28px; }
.page-header, .header-actions, .filter-bar, .dashboard-toolbar, .scheme-toolbar, .section-title, .detail-head, .detail-head > div, .custom-item { display: flex; align-items: center; }
.page-header { justify-content: space-between; margin-bottom: 16px; }
@@ -457,4 +457,64 @@ onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadD
.scheme-toolbar { justify-content: flex-end; margin-bottom: 12px; }.scheme-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid #ebeef5; }.scheme-actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 1000px) { .stats-row { grid-template-columns: repeat(2, 1fr); }.salary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) { .page-header { align-items: flex-start; gap: 12px; flex-direction: column; }.stats-row, .form-grid, .salary-grid { grid-template-columns: 1fr; }.custom-item { flex-wrap: wrap; }.amount-summary { grid-template-columns: 1fr; gap: 10px; }.amount-summary div { border: none; }.detail-summary { grid-template-columns: 1fr; } }
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.dashboard-toolbar {
color: var(--el-text-color-regular);
}
.stat-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.stat-label,
.stat-sub {
color: var(--el-text-color-secondary);
}
.stat-value {
color: var(--el-text-color-primary);
}
}
.table-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.section-title {
color: var(--el-text-color-primary);
}
.salary-section {
border-top-color: var(--el-border-color-lighter);
}
.scheme-form {
border-top-color: var(--el-border-color-lighter);
}
.detail-head {
border-bottom-color: var(--el-border-color-lighter);
span {
color: var(--el-text-color-secondary);
}
}
.detail-summary {
span {
color: var(--el-text-color-secondary);
}
strong {
color: var(--el-text-color-primary);
}
}
.detail-remark {
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
}
.amount-summary {
background: var(--el-fill-color-light);
div {
border-right-color: var(--el-border-color-lighter);
}
}
}
</style>
@@ -631,4 +631,63 @@ onMounted(() => {
flex-wrap: wrap;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.contact-page {
background: var(--el-bg-color-page);
}
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.org-tree-panel {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.tree-header {
border-bottom-color: var(--el-border-color-light);
color: var(--el-text-color-primary);
}
.node-count {
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
}
}
.contact-main {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.filter-bar {
border-bottom-color: var(--el-border-color-light);
}
}
.pagination-wrap {
border-top-color: var(--el-border-color-light);
}
.contact-name {
color: var(--el-color-primary);
}
.star-icon {
color: var(--el-text-color-placeholder);
&:hover {
color: #e6a23c;
}
&.starred {
color: #e6a23c;
}
}
}
</style>
+186 -5
View File
@@ -170,6 +170,7 @@
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
@@ -295,14 +296,19 @@ const statCards = computed(() => [
// 趋势图
const trendChartRef = ref(null);
let trendChart = null;
let themeObserver = null;
const renderTrendChart = () => {
if (!trendChartRef.value) return;
if (!trendChart) trendChart = echarts.init(trendChartRef.value);
const dark = document.documentElement.classList.contains("dark");
const axisLineColor = dark ? "#3a3a3a" : "#E0E6ED";
const labelColor = dark ? "#A3A6AD" : "#606266";
const splitColor = dark ? "#2a2a2a" : "#F2F6FC";
const months = dashboard.trend.map((item) => item.month.slice(5) + "月");
trendChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
legend: { data: ["已报销", "未报销"], top: 0 },
legend: { data: ["已报销", "未报销"], top: 0, textStyle: { color: labelColor } },
grid: {
left: "3%",
right: "4%",
@@ -313,15 +319,15 @@ const renderTrendChart = () => {
xAxis: {
type: "category",
data: months,
axisLine: { lineStyle: { color: "#E0E6ED" } },
axisLabel: { color: "#606266", fontSize: 11 },
axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: { color: labelColor, fontSize: 11 },
},
yAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: "#F2F6FC" } },
axisLabel: { color: "#606266", fontSize: 11 },
splitLine: { lineStyle: { color: splitColor } },
axisLabel: { color: labelColor, fontSize: 11 },
},
series: [
{
@@ -476,6 +482,22 @@ const scheduleDateTag = (item) => {
return `${m}/${d} 周${week}`;
};
// 判断日程是否关联了图片(与详情页 parseImages 语义一致),用于列表标题前展示图片图标
function hasImages(item) {
const imgs = item?.images;
if (!imgs) return false;
if (Array.isArray(imgs)) return imgs.some(img => img && img.url);
if (typeof imgs === "string" && imgs.trim()) {
try {
const parsed = JSON.parse(imgs);
return Array.isArray(parsed) && parsed.some(img => img && img.url);
} catch {
return false;
}
}
return false;
}
const scheduleTimeText = (item) =>
item.all_day === 1
? "全天"
@@ -609,10 +631,18 @@ const statusType = (value) =>
onMounted(async () => {
await loadDashboard();
window.addEventListener("resize", handleResize);
// 主题切换时重绘图表,使坐标轴/文字颜色跟随暗色
themeObserver = new MutationObserver(() => renderTrendChart());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
themeObserver?.disconnect();
themeObserver = null;
trendChart?.dispose();
trendChart = null;
});
@@ -969,6 +999,12 @@ onBeforeUnmount(() => {
}
}
.schedule-image-icon {
flex-shrink: 0;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
.schedule-item-time {
display: flex;
gap: 10px;
@@ -1128,4 +1164,149 @@ onBeforeUnmount(() => {
width: auto;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.oa-dashboard {
background: var(--el-bg-color-page);
}
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.stat-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.stat-title {
color: var(--el-text-color-secondary);
}
.stat-amount {
.currency,
.number {
color: var(--el-text-color-primary);
}
}
.stat-meta {
color: var(--el-text-color-secondary);
}
}
.chart-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
.chart-title {
color: var(--el-text-color-primary);
border-bottom-color: var(--el-border-color-lighter);
}
}
.notice-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
.notice-text {
color: var(--el-text-color-primary);
}
.notice-time {
color: var(--el-text-color-placeholder);
}
}
.notice-detail-meta {
border-bottom-color: var(--el-border-color-lighter);
}
.notice-detail-text {
color: var(--el-text-color-secondary);
}
.notice-detail-content {
color: var(--el-text-color-primary);
}
.tab-count {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.schedule-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
&.done {
background: var(--el-bg-color-page);
border-left-color: var(--el-border-color);
}
.item-status-dot.done {
background: var(--el-text-color-placeholder);
}
.schedule-item-title {
color: var(--el-text-color-primary);
&.done {
color: var(--el-text-color-placeholder);
}
}
.schedule-item-time {
color: var(--el-text-color-secondary);
&.done {
color: var(--el-text-color-placeholder);
}
}
}
.schedule-action-title {
color: var(--el-text-color-primary);
}
.schedule-action-meta {
color: var(--el-text-color-regular);
}
.schedule-action-tip {
color: var(--el-text-color-secondary);
}
.dist-bar-wrap,
.type-bar-wrap {
background: var(--el-fill-color);
}
.dist-count {
color: var(--el-text-color-secondary);
}
.type-name {
color: var(--el-text-color-regular);
}
.type-count {
color: var(--el-text-color-secondary);
}
.type-amount {
color: var(--el-text-color-primary);
}
}
</style>
@@ -340,4 +340,36 @@ onMounted(() => {
justify-content: flex-end;
margin-top: 14px;
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.stat-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.stat-value {
color: var(--el-text-color-primary);
}
.stat-label {
color: var(--el-text-color-secondary);
}
&.info .stat-value {
color: var(--el-text-color-secondary);
}
}
.name-link {
color: var(--el-color-primary);
}
}
</style>
+28 -1
View File
@@ -298,7 +298,7 @@ function formatDateTime(value) {
onMounted(loadList);
</script>
<style scoped>
<style lang="less" scoped>
.notice-page {
padding: 16px 20px 24px;
}
@@ -386,4 +386,31 @@ onMounted(loadList);
overflow-y: auto;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.table-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.title-text:hover {
color: var(--el-color-primary);
}
.notice-detail {
.detail-meta {
border-bottom-color: var(--el-border-color-lighter);
}
.meta-text {
color: var(--el-text-color-secondary);
}
.detail-content {
color: var(--el-text-color-primary);
}
}
}
</style>
@@ -511,4 +511,25 @@ onBeforeUnmount(() => {
margin-top: 12px;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.reimburse-page {
background: var(--el-bg-color-page);
}
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.filter-card,
.table-card {
background: var(--el-bg-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
}
</style>
@@ -43,6 +43,36 @@
<span class="meta-label">延期</span>
<span>{{ carryText }}</span>
</div>
<div v-if="schedule.reminder_id > 0" class="meta-row">
<span class="meta-label">提醒</span>
<span class="reminder-status">
<el-tag v-if="reminderInfo?.is_finished" type="info" size="small"
>已关闭</el-tag
>
<el-tag v-else type="success" size="small">提醒中</el-tag>
<el-tag
v-for="ch in reminderInfo?.remind_channels || []"
:key="ch"
size="small"
effect="plain"
class="reminder-ch"
>{{ channelText(ch) }}</el-tag
>
<span v-if="reminderInfo?.advance_minutes" class="reminder-adv"
>提前 {{ reminderInfo.advance_minutes }} 分</span
>
<el-button
v-if="!reminderInfo?.is_finished"
size="small"
link
type="warning"
:loading="reminderLoading"
@click="endReminder"
>结束提醒</el-button
>
<span v-if="reminderLoading" class="reminder-loading">加载中…</span>
</span>
</div>
<div v-if="schedule.content" class="meta-row">
<span class="meta-label">备注</span>
<span class="meta-content">{{ schedule.content }}</span>
@@ -144,6 +174,7 @@ import {
} from "@element-plus/icons-vue";
import { uploadFile } from "@/api/file";
import { updateScheduleImages } from "@/api/oaSchedule";
import { getReminderDetail, finishReminder } from "@/api/reminder";
const props = defineProps({
modelValue: {
@@ -270,10 +301,63 @@ watch(
() => props.schedule,
schedule => {
imageList.value = parseImages(schedule?.images);
loadReminderInfo(schedule?.reminder_id);
},
{ immediate: true }
);
// ---------- 绑定提醒的状态回显与反馈 ----------
const reminderInfo = ref(null);
const reminderLoading = ref(false);
const channelTextMap = {
SMS: "短信",
EMAIL: "邮件",
BARK: "Bark",
SITE_MSG: "站内信"
};
function channelText(ch) {
return channelTextMap[ch] || ch;
}
async function loadReminderInfo(reminderId) {
reminderInfo.value = null;
if (!reminderId) {
return;
}
reminderLoading.value = true;
try {
const res = await getReminderDetail(reminderId);
if (res?.code === 200) {
reminderInfo.value = res.data || null;
}
} catch {
reminderInfo.value = null;
} finally {
reminderLoading.value = false;
}
}
// 在日程详情直接关闭绑定的提醒任务;关闭后下方标签会回显"已关闭"
async function endReminder() {
const id = props.schedule?.reminder_id;
if (!id) {
return;
}
try {
const res = await finishReminder(id);
if (res?.code === 200) {
ElMessage.success("提醒已关闭");
await loadReminderInfo(id);
emit("changed", props.schedule.id);
} else {
ElMessage.error(res?.msg || "操作失败");
}
} catch {
ElMessage.error("操作失败");
}
}
function validateImage(file) {
if (file.type && !file.type.startsWith("image/")) {
ElMessage.error("只支持上传图片文件");
@@ -418,7 +502,7 @@ function formatDateTime(value) {
}
</script>
<style scoped>
<style lang="less" scoped>
.detail-title-row {
display: flex;
align-items: center;
@@ -469,6 +553,27 @@ function formatDateTime(value) {
word-break: break-all;
}
.reminder-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.reminder-ch {
margin: 0;
}
.reminder-adv {
font-size: 12px;
color: #909399;
}
.reminder-loading {
font-size: 12px;
color: #c0c4cc;
}
/* 相关图片 */
.detail-images {
margin-top: 20px;
@@ -529,4 +634,31 @@ function formatDateTime(value) {
color: #c0c4cc;
text-align: center;
}
// ===== 暗色主题适配 =====
html.dark & {
.detail-title.done {
color: var(--el-text-color-placeholder);
}
.meta-row {
color: var(--el-text-color-primary);
}
.meta-label {
color: var(--el-text-color-secondary);
}
.detail-images {
border-top-color: var(--el-border-color-lighter);
}
.images-title {
color: var(--el-text-color-primary);
}
.images-tip {
color: var(--el-text-color-placeholder);
}
.image-thumb {
background: var(--el-fill-color);
}
.image-empty {
color: var(--el-text-color-placeholder);
}
}
</style>
@@ -77,6 +77,68 @@
placeholder="补充说明(可选)"
/>
</el-form-item>
<el-divider>提醒设置</el-divider>
<el-form-item label="开启提醒">
<el-switch v-model="form.reminder_enabled" @change="onReminderToggle" />
<span class="form-tip">开启后将在「提醒工具」生成一条提醒任务,到点时按渠道通知你</span>
</el-form-item>
<template v-if="form.reminder_enabled">
<el-form-item label="提醒时间" prop="reminder_time">
<el-date-picker
v-model="form.reminder_time"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择提醒时间"
style="width: 100%"
/>
<span class="form-tip"
>默认取日程发生时间,可修改;提醒将在"提前提醒分钟"之前开始送达</span
>
</el-form-item>
<el-form-item label="提醒渠道" prop="remind_channels">
<el-checkbox-group v-model="form.remind_channels">
<el-checkbox value="SMS">短信</el-checkbox>
<el-checkbox value="EMAIL">邮件</el-checkbox>
<el-checkbox value="BARK">Bark</el-checkbox>
<el-checkbox value="SITE_MSG">站内信</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="提前提醒(分钟)" prop="advance_minutes">
<el-input-number
v-model="form.advance_minutes"
:min="0"
:max="1440"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-alert
v-if="missingChannels.length"
type="warning"
:closable="false"
show-icon
class="reminder-alert"
>
<template #title>
未配置「{{ missingChannels.join("、") }}」的接收方式
</template>
<div class="alert-body">
<span>请先在通知设置中配置对应账号,否则该渠道提醒无法送达。</span>
<el-button
type="primary"
size="small"
link
@click="saveDraftAndGotoNotify"
>去设置并暂存当前日程</el-button
>
</div>
</el-alert>
</template>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -89,8 +151,11 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { createSchedule, updateSchedule } from "@/api/oaSchedule";
import { getNotifyConfig } from "@/api/notifyConfig";
import { getReminderDetail } from "@/api/reminder";
const props = defineProps({
modelValue: {
@@ -106,10 +171,17 @@ const props = defineProps({
defaultDate: {
type: String,
default: ""
},
// 从通知设置页返回时恢复的草稿(含提醒设置)
draft: {
type: Object,
default: null
}
});
const emit = defineEmits(["update:modelValue", "saved"]);
const emit = defineEmits(["update:modelValue", "saved", "draft-applied"]);
const router = useRouter();
const visible = computed({
get: () => props.modelValue,
@@ -137,9 +209,18 @@ const form = reactive({
end_time: "",
priority: 0,
color: colorOptions[0],
content: ""
content: "",
// 提醒绑定
reminder_enabled: false,
reminder_time: "",
remind_channels: [],
advance_minutes: 0
});
// 用户通知配置:用于判断所选渠道是否已配置接收账号
const notifyConfig = ref({});
const notifyLoading = ref(false);
const rules = {
title: [{ required: true, message: "请输入日程标题", trigger: "blur" }],
schedule_date: [
@@ -149,7 +230,7 @@ const rules = {
const isEdit = computed(() => !!props.schedule);
// 打开抽屉时根据编辑对象/默认日期重建表单。
// 打开抽屉时根据编辑对象/默认日期/草稿重建表单。
// 用 watch 替代 el-drawer 的 @open + destroy-on-close:
// open 事件早于抽屉内容挂载,时序不稳定会导致表单控件状态错乱、点击无效。
watch(
@@ -161,7 +242,112 @@ watch(
}
);
function resetReminderForm() {
form.reminder_enabled = false;
form.reminder_time = "";
form.remind_channels = [];
form.advance_minutes = 0;
}
// 由日程日期/时间推算默认提醒时间:全天取 09:00,按时段取开始时间。
function defaultReminderTime() {
if (!form.schedule_date) {
return "";
}
if (form.all_day) {
return `${form.schedule_date} 09:00:00`;
}
const t = form.start_time || "09:00";
return `${form.schedule_date} ${t}:00`;
}
// 日程时间变化时,若提醒已开启且用户未手动填写提醒时间,则同步默认值
watch(
() => [form.schedule_date, form.all_day, form.start_time],
() => {
if (form.reminder_enabled && !form.reminder_time) {
form.reminder_time = defaultReminderTime();
}
}
);
function onReminderToggle(val) {
if (val && !form.reminder_time) {
form.reminder_time = defaultReminderTime();
}
}
async function loadNotifyConfig() {
notifyLoading.value = true;
try {
const res = await getNotifyConfig();
if (res?.code === 200) {
notifyConfig.value = res.data || {};
}
} catch {
notifyConfig.value = {};
} finally {
notifyLoading.value = false;
}
}
// 当前选中渠道中,缺少对应接收账号配置的渠道
const missingChannels = computed(() => {
if (!form.reminder_enabled) {
return [];
}
const cfg = notifyConfig.value || {};
const need = [];
for (const ch of form.remind_channels) {
if (ch === "EMAIL" && !cfg.email) {
need.push("邮件");
} else if (ch === "SMS" && !cfg.phone) {
need.push("短信");
} else if (ch === "BARK" && !cfg.bark_device_key) {
need.push("Bark");
}
// SITE_MSG(站内信)无需额外配置
}
return need;
});
// 暂存当前填写内容并跳转到通知设置页
function saveDraftAndGotoNotify() {
const draft = { ...form, schedule_id: undefined };
sessionStorage.setItem("oa_schedule_draft", JSON.stringify(draft));
ElMessage.info("已暂存当前日程,去设置通知后返回即可继续");
router.push("/user/notifyConfig");
}
// 编辑已绑定提醒的日程时,回填提醒设置
async function loadReminderForEdit(reminderId) {
try {
const res = await getReminderDetail(reminderId);
if (res?.code === 200 && res.data) {
const d = res.data;
form.reminder_time = d.schedule_time || defaultReminderTime();
form.remind_channels = d.remind_channels || [];
form.advance_minutes = d.advance_minutes ?? 0;
}
} catch {
// 读取失败时不阻断编辑,退化为基础默认值
form.reminder_time = defaultReminderTime();
}
}
function initForm() {
// 优先应用从通知设置页返回的草稿
if (props.draft) {
editingId.value = 0;
Object.assign(form, props.draft);
if (!form.reminder_time) {
form.reminder_time = defaultReminderTime();
}
emit("draft-applied");
loadNotifyConfig();
return;
}
if (props.schedule) {
editingId.value = props.schedule.id;
form.title = props.schedule.title;
@@ -172,6 +358,13 @@ function initForm() {
form.priority = props.schedule.priority || 0;
form.color = props.schedule.color || colorOptions[0];
form.content = props.schedule.content || "";
// 回显已绑定的提醒任务
if (props.schedule.reminder_id > 0) {
form.reminder_enabled = true;
loadReminderForEdit(props.schedule.reminder_id);
} else {
resetReminderForm();
}
} else {
editingId.value = 0;
form.title = "";
@@ -182,7 +375,9 @@ function initForm() {
form.priority = 0;
form.color = colorOptions[0];
form.content = "";
resetReminderForm();
}
loadNotifyConfig();
}
async function handleSave() {
@@ -202,6 +397,22 @@ async function handleSave() {
}
}
// 开启提醒但所选渠道缺少接收配置:阻断保存,引导先去通知设置
if (form.reminder_enabled && missingChannels.value.length) {
ElMessage.warning(
`请先配置${missingChannels.value.join("、")}的接收方式,或取消该渠道`
);
return;
}
if (form.reminder_enabled && !form.reminder_time) {
ElMessage.warning("请选择提醒时间");
return;
}
if (form.reminder_enabled && form.remind_channels.length === 0) {
ElMessage.warning("请至少选择一个提醒渠道");
return;
}
saving.value = true;
try {
const payload = {
@@ -212,7 +423,13 @@ async function handleSave() {
end_time: form.all_day ? "" : form.end_time || "",
all_day: form.all_day ? 1 : 0,
color: form.color,
priority: form.priority
priority: form.priority,
reminder_enabled: !!form.reminder_enabled,
reminder_time: form.reminder_enabled ? form.reminder_time : "",
remind_channels: form.reminder_enabled ? form.remind_channels : [],
advance_minutes: form.reminder_enabled
? Number(form.advance_minutes || 0)
: 0
};
const res =
isEdit.value
@@ -231,7 +448,7 @@ async function handleSave() {
}
</script>
<style scoped>
<style lang="less" scoped>
.time-range {
display: flex;
align-items: center;
@@ -272,4 +489,38 @@ async function handleSave() {
border-color: #fff;
outline: 2px solid #409eff;
}
.form-tip {
display: block;
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.reminder-alert {
margin-bottom: 8px;
}
.reminder-alert .alert-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.reminder-alert .alert-body span {
font-size: 13px;
}
// ===== 暗色主题适配 =====
html.dark & {
.time-sep {
color: var(--el-text-color-secondary);
}
.color-dot.active {
border-color: var(--el-bg-color);
outline-color: var(--el-color-primary);
}
}
</style>
@@ -163,7 +163,7 @@ async function submit() {
}
</script>
<style scoped>
<style lang="less" scoped>
.postpone-tip {
margin: 0 0 12px;
font-size: 13px;
@@ -184,4 +184,14 @@ async function submit() {
align-items: center;
gap: 8px;
}
// ===== 暗色主题适配 =====
html.dark & {
.postpone-tip {
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
}
.postpone-overdue {
color: #f56c6c;
}
}
</style>
+169 -2
View File
@@ -108,6 +108,12 @@
<span v-if="item.all_day === 0" class="item-time">{{
item.start_time
}}</span>
<i v-if="hasImages(item)" class="fa-solid fa-image item-image-icon"></i>
<i
v-if="item.reminder_id > 0"
class="fa-solid fa-bell item-bell"
title="已开启提醒"
></i>
<span class="item-title" :class="{ done: item.status === 1 }">{{
item.title
}}</span>
@@ -172,6 +178,12 @@
/>
<div class="day-item-main" @click="openDetail(item)">
<div class="day-item-title" :class="{ done: item.status === 1 }">
<i v-if="hasImages(item)" class="fa-solid fa-image item-image-icon"></i>
<i
v-if="item.reminder_id > 0"
class="fa-solid fa-bell item-bell"
title="已开启提醒"
></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
@@ -276,7 +288,11 @@
<span
class="color-chip"
:style="{ background: row.color }"
/>{{ row.title }}
/><i v-if="hasImages(row)" class="fa-solid fa-image item-image-icon"></i><i
v-if="row.reminder_id > 0"
class="fa-solid fa-bell item-image-icon"
title="已开启提醒"
></i>{{ row.title }}
</span>
</template>
</el-table-column>
@@ -341,7 +357,9 @@
v-model="editVisible"
:schedule="editingItem"
:default-date="selectedDate"
:draft="pendingDraft"
@saved="reloadAll"
@draft-applied="pendingDraft = null"
/>
<!-- 日程详情抽屉 -->
@@ -648,6 +666,8 @@ const editVisible = ref(false);
const editingItem = ref(null);
const detailVisible = ref(false);
const detailItem = ref(null);
// 从通知设置页返回时恢复的草稿(含提醒设置)
const pendingDraft = ref(null);
function openCreate(date) {
editingItem.value = null;
@@ -842,6 +862,22 @@ function priorityTagType(priority) {
return { 1: "warning", 2: "danger" }[priority] || "info";
}
// 判断日程是否关联了图片(与详情页 parseImages 语义一致),用于列表标题前展示图片图标
function hasImages(item) {
const imgs = item?.images;
if (!imgs) return false;
if (Array.isArray(imgs)) return imgs.some(img => img && img.url);
if (typeof imgs === "string" && imgs.trim()) {
try {
const parsed = JSON.parse(imgs);
return Array.isArray(parsed) && parsed.some(img => img && img.url);
} catch {
return false;
}
}
return false;
}
// 排序权重:紧急(2) > 重要(1) > 普通(0)
function priorityWeight(priority) {
return { 2: 3, 1: 2 }[priority] || 1;
@@ -864,10 +900,23 @@ function compareByPriority(a, b) {
onMounted(() => {
loadStats();
loadCalendarData();
// 从通知设置页返回:恢复上次暂存的新建日程草稿
const raw = sessionStorage.getItem("oa_schedule_draft");
if (raw) {
try {
pendingDraft.value = JSON.parse(raw);
sessionStorage.removeItem("oa_schedule_draft");
editingItem.value = null;
editVisible.value = true;
ElMessage.info("已从暂存恢复未提交的新建日程");
} catch {
sessionStorage.removeItem("oa_schedule_draft");
}
}
});
</script>
<style scoped>
<style lang="less" scoped>
.schedule-page {
padding: 16px 20px 24px;
}
@@ -1113,6 +1162,18 @@ onMounted(() => {
text-overflow: ellipsis;
}
.item-image-icon {
flex-shrink: 0;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
.item-bell {
flex-shrink: 0;
margin-right: 4px;
color: #e6a23c;
}
.item-title.done {
text-decoration: line-through;
color: #c0c4cc;
@@ -1290,4 +1351,110 @@ onMounted(() => {
background: #409eff;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.stat-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-sub {
color: var(--el-text-color-placeholder);
}
}
.category-tabs {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.tab-count {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.calendar-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.calendar-week-header {
border-bottom-color: var(--el-border-color-lighter);
}
.week-name {
color: var(--el-text-color-secondary);
}
.calendar-cell {
border-right-color: var(--el-border-color-lighter);
border-bottom-color: var(--el-border-color-lighter);
&:hover {
background: var(--el-fill-color-light);
}
&.other-month {
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
}
&.selected {
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
}
.today-tag,
.month-tag {
color: var(--el-color-primary);
}
.cell-item {
background: var(--el-fill-color-light);
&:hover {
background: var(--el-fill-color);
}
}
.item-time {
color: var(--el-text-color-secondary);
}
.item-title.done {
color: var(--el-text-color-placeholder);
}
.cell-more {
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-primary);
}
}
.day-panel {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.day-panel-header {
border-bottom-color: var(--el-border-color-lighter);
}
.day-sub {
color: var(--el-text-color-secondary);
}
.day-item {
background: var(--el-fill-color-light);
}
.day-item-title.done {
color: var(--el-text-color-placeholder);
}
.day-item-time {
color: var(--el-text-color-secondary);
}
.table-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.list-title.done {
color: var(--el-text-color-placeholder);
}
}
</style>
@@ -614,4 +614,32 @@ onMounted(() => {
width: 100%;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header {
h2 {
color: var(--el-text-color-primary);
}
p {
color: var(--el-text-color-secondary);
}
}
.stat-value {
color: var(--el-text-color-primary);
}
.stat-label {
color: var(--el-text-color-secondary);
}
.node-label {
color: var(--el-text-color-primary);
}
.section-title {
color: var(--el-text-color-primary);
}
}
</style>
@@ -13,12 +13,17 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分类">
<el-tag :type="roleDetail.cid === 1 ? 'warning' : 'primary'" size="small">
{{ roleDetail.cid === 1 ? "平台角色" : "租户角色" }}
<el-tag v-if="roleDetail.cid === 1" type="danger" size="small">平台角色</el-tag>
<el-tag v-else-if="Number(roleDetail.is_custom) === 1" type="warning" size="small">
自定义角色
</el-tag>
<el-tag v-else type="primary" size="small">租户角色</el-tag>
</el-descriptions-item>
<el-descriptions-item label="权限列表">
<div v-if="menuNames.length > 0" style="max-height: 300px; overflow-y: auto;">
<div v-if="!roleDetail.rights" style="color: var(--el-text-color-secondary);">
全权限(未做限制)
</div>
<div v-else-if="menuNames.length > 0" style="max-height: 300px; overflow-y: auto;">
<el-tag
v-for="(name, index) in menuNames"
:key="index"
@@ -49,7 +54,7 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ElMessage } from "element-plus";
import { getPlatformRoleById } from "@/api/platformRole";
import { getRoleById } from "@/api/role";
import { getAllMenus } from "@/api/menu";
interface Props {
@@ -76,7 +81,7 @@ watch(
visible.value = val;
if (val && props.roleId) {
await loadRoleDetail();
await loadMenus(roleDetail.value.cid);
await loadMenus();
}
}
);
@@ -132,7 +137,7 @@ const loadRoleDetail = async () => {
loading.value = true;
try {
const res = await getPlatformRoleById(props.roleId);
const res = await getRoleById(props.roleId);
if (res.code === 200) {
roleDetail.value = res.data || {};
} else {
@@ -146,12 +151,10 @@ const loadRoleDetail = async () => {
}
};
// 加载菜单树:租户角色(cid=2)只展示租户端菜单;平台角色展示全部权限
const loadMenus = async (cid?: number) => {
// 加载菜单树:租户端只展示租户端菜单(cid=2)
const loadMenus = async () => {
try {
const isTenant = cid === undefined || cid === 2;
const params = isTenant ? { cid: 2 } : {};
const res = await getAllMenus(params);
const res = await getAllMenus({ cid: 2 });
if (res.code === 200) {
allMenus.value = res.data || [];
}
@@ -1,5 +1,10 @@
<template>
<el-dialog v-model="visible" :title="isEdit ? '编辑角色' : '添加角色'" width="600px" @close="handleClose">
<el-dialog
v-model="visible"
:title="isEdit ? '编辑租户角色' : '添加自定义角色'"
width="600px"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" placeholder="请输入角色名称" maxlength="50" show-word-limit />
@@ -12,12 +17,13 @@
</el-radio-group>
</el-form-item>
<el-form-item label="分类" prop="cid">
<el-radio-group v-model="form.cid" @change="onCidChange">
<el-radio :label="2">租户角色</el-radio>
<el-radio :label="1">平台角色</el-radio>
</el-radio-group>
<div class="form-tip">租户角色仅显示租户端菜单;平台角色显示全部权限</div>
<el-form-item label="角色类型">
<el-tag :type="form.is_custom ? 'warning' : 'primary'" size="small">
{{ form.is_custom ? "自定义角色" : "系统预置租户角色" }}
</el-tag>
<div class="form-tip">
当前为租户端,仅可维护本租户的自定义角色:权限范围限定在租户端菜单,数据按租户隔离,平台端不可见。
</div>
</el-form-item>
<el-form-item label="权限设置" prop="rights">
@@ -46,7 +52,7 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { createPlatformRole, updatePlatformRole } from "@/api/platformRole";
import { createRole, updateRole } from "@/api/role";
import { getAllMenus } from "@/api/menu";
interface Props {
@@ -71,10 +77,11 @@ const menuTree = ref<any[]>([]);
const isAllSelected = ref(false);
const isExpandAll = ref(false);
// 租户端只处理租户角色(cid=2),分类固定,不再提供平台角色选项
const form = ref({
name: "",
status: 1,
cid: 2,
is_custom: 1,
rights: [] as number[],
});
@@ -148,12 +155,10 @@ const toggleSelectAll = () => {
// --- 数据加载与监听 ---
// 加载菜单树数据
// 租户角色(cid=2)只展示租户端菜单;平台角色(cid=1)展示全部权限
// 加载菜单树数据:租户端只展示租户端菜单(cid=2),平台菜单不在此分配
const loadMenus = async () => {
try {
const params = form.value.cid === 2 ? { cid: 2 } : {};
const res = await getAllMenus(params);
const res = await getAllMenus({ cid: 2 });
if (res.code === 200) {
menuTree.value = res.data || [];
}
@@ -163,18 +168,6 @@ const loadMenus = async () => {
}
};
// 切换分类时重新加载菜单树并回显已选权限
const onCidChange = async () => {
if (!visible.value) return;
await loadMenus();
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(form.value.rights);
updateSelectStatus();
}
});
};
// 解析权限数据
const parseRights = (rights: any): number[] => {
if (!rights) return [];
@@ -205,7 +198,7 @@ watch(
form.value = {
name: props.role.name,
status: props.role.status,
cid: props.role.cid === 1 ? 1 : 2,
is_custom: props.role.is_custom ? 1 : 0,
rights: parseRights(props.role.rights),
};
// 设置树的回显
@@ -234,7 +227,7 @@ const resetForm = () => {
form.value = {
name: "",
status: 1,
cid: 2,
is_custom: 1,
rights: [],
};
if (formRef.value) formRef.value.clearValidate();
@@ -255,25 +248,20 @@ const handleSubmit = async () => {
// 只获取完全选中的节点(不包含半选父节点)
const checkedKeys = treeRef.value.getCheckedKeys();
// 租户端只提交基础字段,cid/tenant_id/is_custom 由服务端按当前租户写入
const submitData: any = {
name: form.value.name,
status: form.value.status,
cid: form.value.cid,
rights: checkedKeys,
};
// 编辑时添加 id 字段
if (isEdit.value && props.role) {
submitData.id = props.role.id;
}
submitting.value = true;
let res;
if (isEdit.value && props.role) {
res = await updatePlatformRole(props.role.id, submitData);
res = await updateRole(props.role.id, submitData);
} else {
res = await createPlatformRole(submitData);
res = await createRole(submitData);
}
if (res.code === 200) {
+14 -28
View File
@@ -5,7 +5,7 @@
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加角色
添加自定义角色
</el-button>
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
@@ -19,8 +19,8 @@
<div class="filter-bar">
<el-radio-group v-model="typeFilter">
<el-radio-button :value="'all'">全部</el-radio-button>
<el-radio-button :value="1">平台角色</el-radio-button>
<el-radio-button :value="2">租户角色</el-radio-button>
<el-radio-button :value="'custom'">自定义角色</el-radio-button>
</el-radio-group>
</div>
@@ -39,11 +39,13 @@
min-width="150"
align="center"
/>
<el-table-column prop="cid" label="类型" width="120" align="center">
<el-table-column prop="cid" label="类型" width="150" align="center">
<template #default="{ row }">
<el-tag :type="row.cid === 1 ? 'warning' : 'primary'" size="small">
{{ row.cid === 1 ? "平台角色" : "租户角色" }}
<el-tag v-if="row.cid === 1" type="danger" size="small">平台角色</el-tag>
<el-tag v-else-if="Number(row.is_custom) === 1" type="warning" size="small">
自定义角色
</el-tag>
<el-tag v-else type="primary" size="small">租户角色</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
@@ -71,7 +73,6 @@
详情
</el-button>
<el-button
v-if="userInfo?.id === 2"
size="small"
type="primary"
link
@@ -81,7 +82,7 @@
编辑
</el-button>
<el-button
v-if="userInfo?.id === 2"
v-if="Number(row.is_custom) === 1"
size="small"
type="danger"
link
@@ -114,23 +115,18 @@
import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, View, Edit, Delete, Refresh } from "@element-plus/icons-vue";
import {
getAllPlatformRoles,
deletePlatformRole,
} from "@/api/platformRole";
import { getAllRoles, deleteRole } from "@/api/role";
import RoleEditDialog from "./components/edit.vue";
import RoleDetailDialog from "./components/detail.vue";
import { useAuthStore } from "@/stores/auth";
// 使用 auth store 获取用户信息
const authStore = useAuthStore();
const roles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const typeFilter = ref<string | number>("all");
const displayRoles = computed(() => {
// custom = 本租户自建的自定义角色;2 = 租户角色(含系统预置)
if (typeFilter.value === "all") return roles.value;
if (typeFilter.value === "custom") return roles.value.filter((r) => Number(r.is_custom) === 1);
return roles.value.filter((r) => Number(r.cid) === Number(typeFilter.value));
});
@@ -139,22 +135,12 @@ const detailDialogVisible = ref(false);
const currentRole = ref<any>(null);
const currentRoleId = ref<number | null>(null);
// 获取用户信息
const userInfo = authStore.user;
if (userInfo && userInfo.id) {
// console.log('用户名:', userInfo.username || userInfo.nickname);
// console.log('用户ID:', userInfo.id);
// console.log('角色:', userInfo.role);
} else {
// console.log('未找到用户信息或用户未登录');
}
// 获取角色列表
// 获取角色列表:仅返回当前登录租户名下的租户角色(服务端按租户隔离)
const fetchRoles = async () => {
loading.value = true;
error.value = "";
try {
const res = await getAllPlatformRoles();
const res = await getAllRoles();
if (res.code === 200) {
roles.value = res.data || [];
// console.log('角色列表:', roles.value);
@@ -207,7 +193,7 @@ async function handleDelete(row: any) {
loading.value = true;
try {
const res = await deletePlatformRole(row.id);
const res = await deleteRole(row.id);
if (res.code === 200) {
ElMessage.success("删除成功");
fetchRoles();
+56 -1
View File
@@ -3,7 +3,14 @@
<div class="home-top">
<div class="toolbar">
<div class="toolbar-left">
<span class="system-name">系统管理平台</span>
<span class="system-name">云泽管理平台</span>
<template v-if="tenantName">
<span class="tenant-divider"></span>
<span class="tenant-name" :title="tenantName">
<el-icon class="tenant-icon"><OfficeBuilding /></el-icon>
{{ tenantName }}
</span>
</template>
</div>
<div class="toolbar-right">
<el-tooltip content="刷新菜单" placement="bottom">
@@ -144,6 +151,7 @@ import {
RefreshRight,
Sunny,
Moon,
OfficeBuilding,
} from "@element-plus/icons-vue";
import { getTenantList } from "@/api/modules";
@@ -221,6 +229,9 @@ const userName = computed(
);
const userAvatar = computed(() => authStore.user?.avatar || "");
// 当前租户(企业)名称,来自后端 /backend/getCurrentUser 或登录接口返回的 tenant_name
const tenantName = computed(() => authStore.user?.tenant_name || "");
// 处理导航跳转
function handleNavigate(module: ModuleItem) {
const isWebsiteManagement = module.name === "网站管理" || module.path.startsWith("/apps/cms");
@@ -301,6 +312,10 @@ async function loadModules() {
onMounted(() => {
loadModules();
initTheme();
// 兜底:若登录时未带租户名称(老会话/缓存),主动拉取一次以显示企业名称
if (!tenantName.value) {
authStore.fetchCurrentUser();
}
});
</script>
@@ -339,6 +354,32 @@ onMounted(() => {
font-weight: 600;
color: @text-main;
}
.tenant-divider {
width: 1px;
height: 16px;
background: @text-regular;
opacity: 0.25;
margin: 0 4px;
}
.tenant-name {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 15px;
font-weight: 500;
color: @text-regular;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.tenant-icon {
font-size: 16px;
color: #3973ff;
}
}
}
.toolbar-right {
@@ -738,6 +779,12 @@ onMounted(() => {
}
}
}
.toolbar-left {
.tenant-name {
display: none;
}
}
}
}
@@ -791,6 +838,14 @@ onMounted(() => {
color: #e0e0e0;
}
.toolbar-left .tenant-name {
color: #b0b0b0;
.tenant-icon {
color: #5a8dff;
}
}
.toolbar-right {
.toolbar-btn {
background: #2d2d2d;
+188 -16
View File
@@ -2,6 +2,7 @@ package controllers
import (
"encoding/json"
"fmt"
"io"
"regexp"
"strconv"
@@ -289,14 +290,103 @@ func (c *BackendOaScheduleController) Stats() {
}
type oaSchedulePayload struct {
Title string `json:"title"`
Content string `json:"content"`
ScheduleDate string `json:"schedule_date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
AllDay int8 `json:"all_day"`
Color string `json:"color"`
Priority int8 `json:"priority"`
Title string `json:"title"`
Content string `json:"content"`
ScheduleDate string `json:"schedule_date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
AllDay int8 `json:"all_day"`
Color string `json:"color"`
Priority int8 `json:"priority"`
// 提醒绑定:开启后会在提醒工具生成一条提醒任务,并把其 ID 回写到 ReminderID。
ReminderEnabled bool `json:"reminder_enabled"`
ReminderTime string `json:"reminder_time"`
RemindChannels []string `json:"remind_channels"`
AdvanceMinutes int `json:"advance_minutes"`
}
// oaReminderChannels 允许的提醒渠道集合。
var oaReminderChannels = map[string]bool{
"SMS": true, "EMAIL": true, "BARK": true, "SITE_MSG": true,
}
// createOaScheduleReminder 为 OA 日程创建一条绑定的提醒任务。
// 复用提醒工具的数据模型(yz_backend_schedule + yz_backend_schedule_reminder):
// 生成一条 BackendSchedule,再按渠道逐条生成提醒记录,返回 BackendSchedule.ID
// 作为 OA 日程的 reminder_id。EMAIL/BARK 渠道会附带确认回执令牌与重复发送配置。
func createOaScheduleReminder(tid int, userID uint64, title, content, reminderTime string, channels []string, advanceMinutes int) (uint64, error) {
if strings.TrimSpace(reminderTime) == "" {
return 0, fmt.Errorf("提醒时间不能为空")
}
schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", reminderTime, time.Local)
if err != nil {
return 0, fmt.Errorf("提醒时间格式不合法")
}
if advanceMinutes < 0 {
advanceMinutes = 0
}
title = strings.TrimSpace(title)
if title == "" {
title = "日程提醒"
}
bs := models.BackendSchedule{
Tid: tid,
Title: title,
Content: content,
ScheduleTime: schedTime,
UserID: userID,
}
schedID, err := models.Orm.Insert(&bs)
if err != nil {
return 0, fmt.Errorf("创建提醒任务失败: %v", err)
}
now := time.Now()
for _, ch := range channels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if !oaReminderChannels[ch] {
continue
}
firstSend := schedTime.Add(-time.Duration(advanceMinutes) * time.Minute)
reminder := models.BackendScheduleReminder{
Tid: tid,
ScheduleID: uint64(schedID),
RemindChannel: ch,
AdvanceMinutes: advanceMinutes,
NextRemindTime: firstSend,
ReceiverUserID: userID,
RemindStatus: 0,
CreateTime: now,
UpdateTime: now,
}
if ch == "EMAIL" || ch == "BARK" {
token := backendGenerateToken()
reminder.AckToken = &token
reminder.RepeatIntervalMinutes = 10
reminder.MaxSendCount = 5
} else {
reminder.RepeatIntervalMinutes = 0
reminder.MaxSendCount = 1
}
if _, err := models.Orm.Insert(&reminder); err != nil {
return 0, fmt.Errorf("创建提醒失败: %v", err)
}
}
return uint64(schedID), nil
}
// deleteBoundReminder 软删除 OA 日程绑定的提醒任务及其提醒记录。
// 删除日程或关闭提醒时使用,避免提醒工具里残留孤立任务。
func deleteBoundReminder(scheduleID uint64) {
if scheduleID == 0 {
return
}
_, _ = models.Orm.QueryTable(new(models.BackendSchedule)).
Filter("id", scheduleID).Delete()
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
Filter("schedule_id", scheduleID).
Update(map[string]interface{}{"IsDeleted": 1, "UpdateTime": time.Now()})
}
// oaScheduleImage 日程相关图片(截图/照片),只存文件引用,不存二进制。
@@ -393,6 +483,29 @@ func (c *BackendOaScheduleController) parseOaSchedulePayload() (oaSchedulePayloa
if payload.Color == "" {
payload.Color = "#409EFF"
}
// 提醒绑定校验:开启提醒时必须选择渠道且提醒时间合法。
if payload.ReminderEnabled {
validChannels := make([]string, 0, len(payload.RemindChannels))
for _, ch := range payload.RemindChannels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if oaReminderChannels[ch] {
validChannels = append(validChannels, ch)
}
}
payload.RemindChannels = validChannels
if len(validChannels) == 0 {
c.oasJsonErr(400, 400, "请至少选择一个提醒渠道")
return payload, false
}
if _, err := time.ParseInLocation("2006-01-02 15:04:05", payload.ReminderTime, time.Local); err != nil {
c.oasJsonErr(400, 400, "提醒时间格式不合法,支持 YYYY-MM-DD HH:mm:ss")
return payload, false
}
if payload.AdvanceMinutes < 0 {
payload.AdvanceMinutes = 0
}
}
return payload, true
}
@@ -432,6 +545,30 @@ func (c *BackendOaScheduleController) Create() {
return
}
// 开启提醒:生成绑定的提醒任务,并把其 ID 回写到日程。
if payload.ReminderEnabled {
rid, rerr := createOaScheduleReminder(
claims.TenantId,
uint64(claims.UserID),
payload.Title,
payload.Content,
payload.ReminderTime,
payload.RemindChannels,
payload.AdvanceMinutes,
)
if rerr != nil {
// 回滚已创建的日程,避免产生孤立日程
_, _ = models.Orm.QueryTable(new(models.OaSchedule)).Filter("id", item.ID).Delete()
c.oasJsonErr(500, 500, rerr.Error())
return
}
item.ReminderID = rid
if _, err := models.Orm.Update(item, "ReminderID"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
}
c.oasOk(item)
}
@@ -477,9 +614,34 @@ func (c *BackendOaScheduleController) Update() {
item.Priority = payload.Priority
item.UpdateTime = &now
// 重新绑定提醒:先创建新提醒,成功后再删除旧提醒(关闭提醒则清掉绑定)。
// 顺序反过来可避免"新提醒创建失败却删掉了原提醒"导致提醒丢失。
if payload.ReminderEnabled {
rid, rerr := createOaScheduleReminder(
claims.TenantId,
uint64(claims.UserID),
payload.Title,
payload.Content,
payload.ReminderTime,
payload.RemindChannels,
payload.AdvanceMinutes,
)
if rerr != nil {
c.oasJsonErr(500, 500, rerr.Error())
return
}
if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
}
item.ReminderID = rid
} else if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
item.ReminderID = 0
}
if _, err := models.Orm.Update(&item,
"Title", "Content", "ScheduleDate", "StartTime", "EndTime",
"AllDay", "Color", "Priority", "UpdateTime"); err != nil {
"AllDay", "Color", "Priority", "ReminderID", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
@@ -555,21 +717,31 @@ func (c *BackendOaScheduleController) Delete() {
}
now := time.Now()
n, err := models.Orm.QueryTable(new(models.OaSchedule)).
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).
Update(map[string]interface{}{
"IsDeleted": int8(1),
"DeleteTime": now,
"UpdateTime": now,
})
if err != nil || n == 0 {
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在或已删除")
return
}
// 先删除绑定的提醒任务,避免提醒工具里残留孤立任务
if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
}
item.IsDeleted = 1
item.DeleteTime = &now
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "IsDeleted", "DeleteTime", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "删除失败")
return
}
c.oasOk(nil)
}
+2
View File
@@ -138,9 +138,11 @@ func (c *BackendRoleController) CreateRole() {
status = *p.Status
}
rights := normalizeRights(p.Rights)
// 租户端创建的角色一律标记为“自定义角色”,与系统/平台预置角色区分,并按租户隔离
role := &models.AdminRole{
TenantID: tid,
Cid: 2,
IsCustom: 1,
Name: p.Name,
Status: status,
Rights: rights,
+1
View File
@@ -7,6 +7,7 @@ type AdminRole struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID uint64 `orm:"column(tenant_id);default(0)" json:"tenant_id"` // 租户ID:0=平台/全局,>0=具体租户(仅 cid=2 租户角色使用)
Cid uint8 `orm:"column(cid);default(1)" json:"cid"` // 1平台角色 2租户角色
IsCustom uint8 `orm:"column(is_custom);default(0)" json:"is_custom"` // 是否租户自定义角色:0=系统/平台预置 1=租户在租户端自建(数据按租户隔离)
Name string `orm:"column(name);size(32)" json:"name"`
Status uint8 `orm:"column(status);default(1)" json:"status"`
Rights *string `orm:"column(rights);type(text);null" json:"rights"`
+17 -1
View File
@@ -7,6 +7,7 @@ import (
)
var adminRoleTenantColOnce sync.Once
var adminRoleCustomColOnce sync.Once
// EnsureAdminRoleTenantColumn 为角色表补齐 tenant_id 列(历史库可能早于租户隔离特性建表)。
// 字段已存在时 MySQL 报 duplicate column,统一忽略,不阻断主流程。
@@ -22,6 +23,20 @@ func EnsureAdminRoleTenantColumn() {
})
}
// EnsureAdminRoleCustomColumn 为角色表补齐 is_custom 列(历史库可能早于自定义角色特性建表)。
// 字段已存在时 MySQL 报 duplicate column,统一忽略,不阻断主流程。
func EnsureAdminRoleCustomColumn() {
if Orm == nil {
return
}
adminRoleCustomColOnce.Do(func() {
_, _ = Orm.Raw(`
ALTER TABLE yz_system_admin_role
ADD COLUMN is_custom tinyint unsigned NOT NULL DEFAULT 0 COMMENT '自定义角色 0-系统/平台预置 1-租户自建'
`).Exec()
})
}
// EnsureDefaultTenantRoles 保证指定租户(cid=2)的默认角色齐全:
// - 租户管理员:全权限(rights 为空,见 permission.go 空权限即放行)
// - 部门负责人
@@ -33,8 +48,9 @@ func EnsureDefaultTenantRoles(tid uint64) {
if Orm == nil || tid == 0 {
return
}
// 确保 tenant_id 列存在(兼容老表)
// 确保 tenant_id / is_custom 列存在(兼容老表)
EnsureAdminRoleTenantColumn()
EnsureAdminRoleCustomColumn()
defaults := []string{"租户管理员", "部门负责人", "员工"}
for _, name := range defaults {
+4
View File
@@ -123,4 +123,8 @@ func Init(_ string) {
// 创建全局 Ormer
Orm = orm.NewOrm()
// 补齐历史库缺失的列(已存在时 MySQL 报 duplicate column,统一忽略)
EnsureAdminRoleTenantColumn()
EnsureAdminRoleCustomColumn()
}
+4
View File
@@ -35,6 +35,9 @@ type OaSchedule struct {
CarryOver int8 `orm:"column(carry_over);default(0)" json:"carry_over"`
CarryCount int `orm:"column(carry_count);default(0)" json:"carry_count"`
CarryToDate string `orm:"column(carry_to_date);size(10);default()" json:"carry_to_date"`
// ReminderID 绑定到提醒工具的提醒任务(yz_backend_schedule.id),0 表示未开启提醒。
// 用于把 OA 日程与提醒任务关联,从而在日程详情回显提醒状态(如"提醒已关闭")。
ReminderID uint64 `orm:"column(reminder_id);default(0)" json:"reminder_id"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
@@ -62,6 +65,7 @@ func EnsureOaScheduleCarryColumns() error {
"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}]'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN reminder_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '绑定的提醒任务ID(对应yz_backend_schedule.id) 0-无提醒'",
}
for _, sql := range alters {
if _, e := Orm.Raw(sql).Exec(); e != nil {
@@ -111,6 +111,10 @@ const getMonthLabels = (): string[] => {
const initChart = (refEl: HTMLElement, title: string, color: string) => {
const chart = echarts.init(refEl);
const months = getMonthLabels();
const isDark = document.documentElement.classList.contains('dark');
const palette = isDark
? { axis: '#4c4d4f', label: '#a8abb2', split: '#2b2b2d' }
: { axis: '#E0E6ED', label: '#606266', split: '#F2F6FC' };
chart.setOption({
tooltip: {
@@ -128,15 +132,15 @@ const initChart = (refEl: HTMLElement, title: string, color: string) => {
type: 'category',
boundaryGap: false,
data: months,
axisLine: { lineStyle: { color: '#E0E6ED' } },
axisLabel: { color: '#606266', fontSize: 11 }
axisLine: { lineStyle: { color: palette.axis } },
axisLabel: { color: palette.label, fontSize: 11 }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#F2F6FC' } },
axisLabel: { color: '#606266', fontSize: 11 }
splitLine: { lineStyle: { color: palette.split } },
axisLabel: { color: palette.label, fontSize: 11 }
},
series: [
{
@@ -326,4 +330,40 @@ onUnmounted(() => {
min-height: 0;
}
}
// ===== 暗色主题适配 =====
html.dark & {
.erp-dashboard {
background-color: var(--el-bg-color-page);
}
.stat-card {
background: var(--el-bg-color);
.stat-header {
.stat-label {
color: var(--el-text-color-regular);
}
.stat-icon {
color: var(--el-text-color-secondary);
}
}
.stat-value {
.currency,
.number {
color: var(--el-text-color-primary);
}
}
}
.chart-card {
background: var(--el-bg-color);
.chart-title {
color: var(--el-text-color-primary);
border-bottom-color: var(--el-border-color-lighter);
}
}
}
</style>