更新角色管理相关内容
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -123,4 +123,8 @@ func Init(_ string) {
|
||||
|
||||
// 创建全局 Ormer
|
||||
Orm = orm.NewOrm()
|
||||
|
||||
// 补齐历史库缺失的列(已存在时 MySQL 报 duplicate column,统一忽略)
|
||||
EnsureAdminRoleTenantColumn()
|
||||
EnsureAdminRoleCustomColumn()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user