7dcb22f573
- 新增 holidays.py 服务:按年获取节假日数据,缓存到 system_config - 区分 holiday(工作日假日)和 workday(调休上班日)两种类型 - is_working_day() 支持调休判断:周末在 workdays 集合中视为工作日 - 启动时自动从 API 刷新当年+明年数据,失败则使用缓存 - 系统设置新增「从 API 刷新」按钮,支局长可手动触发 - 支持 2010-2026 年数据,API 免费免鉴权 Co-Authored-By: Claude <noreply@anthropic.com>
360 lines
16 KiB
Vue
360 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import api from '@/api/index'
|
||
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
|
||
|
||
const themeStore = useThemeStore()
|
||
|
||
const managers = ref<any[]>([])
|
||
const selectedUserIds = ref<string[]>([])
|
||
const remindMessage = ref('')
|
||
const announcementContent = ref('')
|
||
const remindLoading = ref(false)
|
||
const announceLoading = ref(false)
|
||
const dailyCheckLoading = ref(false)
|
||
const notificationTime = ref('17:30')
|
||
const notificationTimeLoading = ref(false)
|
||
const holidays = ref('')
|
||
const holidaysLoading = ref(false)
|
||
const holidaysRefreshing = ref(false)
|
||
|
||
const importLoading = ref(false)
|
||
const importFile = ref<File | null>(null)
|
||
const importPreview = ref<any>(null)
|
||
const importResult = ref<any>(null)
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const res = await api.get('/users/', { params: { role: 'manager' } })
|
||
managers.value = res.data
|
||
} catch (_) {}
|
||
// Load current notification time
|
||
try {
|
||
const res = await api.get('/system-config')
|
||
if (res.data?.notification_time) {
|
||
notificationTime.value = res.data.notification_time
|
||
}
|
||
if (res.data?.holidays) {
|
||
holidays.value = res.data.holidays
|
||
}
|
||
} catch (_) {}
|
||
})
|
||
|
||
async function handleRemind() {
|
||
if (!selectedUserIds.value.length) { ElMessage.warning('请选择要提醒的人员'); return }
|
||
remindLoading.value = true
|
||
try {
|
||
const res = await api.post('/wecom/remind', { user_ids: selectedUserIds.value, message: remindMessage.value || undefined })
|
||
ElMessage.success(`已发送提醒给 ${res.data.sent_to} 人`)
|
||
} catch (e: any) { ElMessage.error('发送失败') }
|
||
finally { remindLoading.value = false }
|
||
}
|
||
|
||
async function handleAnnouncement() {
|
||
if (!announcementContent.value) { ElMessage.warning('请输入公告内容'); return }
|
||
announceLoading.value = true
|
||
try {
|
||
const res = await api.post('/wecom/announcement', { content: announcementContent.value })
|
||
ElMessage.success(`公告已推送给 ${res.data.sent_to} 人`)
|
||
announcementContent.value = ''
|
||
} catch (e: any) { ElMessage.error('推送失败') }
|
||
finally { announceLoading.value = false }
|
||
}
|
||
|
||
async function handleDailyCheck() {
|
||
dailyCheckLoading.value = true
|
||
try {
|
||
const res = await api.post('/wecom/trigger-daily-check')
|
||
ElMessage.success(`已执行:${res.data.reported}/${res.data.total_managers} 人已填报`)
|
||
} catch (e: any) { ElMessage.error('执行失败') }
|
||
finally { dailyCheckLoading.value = false }
|
||
}
|
||
|
||
async function handleSaveNotificationTime() {
|
||
notificationTimeLoading.value = true
|
||
try {
|
||
await api.put('/system-config/notification_time', { value: notificationTime.value })
|
||
ElMessage.success(`通报时间已更新为 ${notificationTime.value}`)
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||
} finally { notificationTimeLoading.value = false }
|
||
}
|
||
|
||
async function handleSaveHolidays() {
|
||
holidaysLoading.value = true
|
||
try {
|
||
await api.put('/system-config/holidays', { value: holidays.value })
|
||
ElMessage.success('节假日已更新')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||
} finally { holidaysLoading.value = false }
|
||
}
|
||
|
||
async function handleRefreshHolidays() {
|
||
holidaysRefreshing.value = true
|
||
try {
|
||
const res = await api.post('/system-config/refresh-holidays')
|
||
ElMessage.success(`节假日数据已刷新:${res.data.holidays} 个假日,${res.data.workdays} 个调休`)
|
||
// Reload the config display
|
||
const cfg = await api.get('/system-config')
|
||
if (cfg.data?.holidays) holidays.value = cfg.data.holidays
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '刷新失败')
|
||
} finally { holidaysRefreshing.value = false }
|
||
}
|
||
|
||
function handleImportFile(e: Event) {
|
||
const target = e.target as HTMLInputElement
|
||
if (target.files?.[0]) importFile.value = target.files[0]
|
||
}
|
||
|
||
async function downloadWeeklyTemplate() {
|
||
try {
|
||
const res = await api.get('/import/template', { responseType: 'blob' })
|
||
const url = URL.createObjectURL(res.data)
|
||
const a = document.createElement('a'); a.href = url; a.download = 'weekly_report_template.xlsx'; a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch (e: any) { ElMessage.error('下载失败') }
|
||
}
|
||
|
||
async function handleImportPreview() {
|
||
if (!importFile.value) return
|
||
importLoading.value = true
|
||
const formData = new FormData()
|
||
formData.append('file', importFile.value)
|
||
try {
|
||
const res = await api.post('/import/weekly-report', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||
importPreview.value = res.data.preview
|
||
importResult.value = res.data
|
||
} catch (e: any) { ElMessage.error('解析失败') }
|
||
finally { importLoading.value = false }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="settings-page">
|
||
<div class="page-head">
|
||
<h2 class="page-title">系统设置</h2>
|
||
<div class="page-rule"></div>
|
||
</div>
|
||
|
||
<!-- Theme -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||
</svg>
|
||
界面主题
|
||
</div>
|
||
</template>
|
||
<div class="theme-options">
|
||
<label v-for="t in (['editorial', 'light'] as Theme[])" :key="t" class="theme-opt" :class="{ 'theme-opt--active': themeStore.currentTheme === t }" @click="themeStore.setTheme(t)">
|
||
<span class="theme-opt-radio">{{ themeStore.currentTheme === t ? '●' : '○' }}</span>
|
||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||
</label>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- Manual Remind -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<path d="M22 17H2a3 3 0 0 0 3-3V9a7 7 0 0 1 14 0v5a3 3 0 0 0 3 3zm-8.27 4a2 2 0 0 1-3.46 0"></path>
|
||
</svg>
|
||
手动催办
|
||
</div>
|
||
</template>
|
||
<el-form>
|
||
<el-form-item label="选择人员">
|
||
<el-select v-model="selectedUserIds" multiple placeholder="选择客户经理" style="width:100%">
|
||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="提醒内容">
|
||
<el-input v-model="remindMessage" type="textarea" :rows="2" placeholder="可选,留空使用默认提醒语" />
|
||
</el-form-item>
|
||
<el-button type="primary" :loading="remindLoading" @click="handleRemind">发送催办</el-button>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<!-- Announcement -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||
</svg>
|
||
全员公告
|
||
</div>
|
||
</template>
|
||
<el-form>
|
||
<el-form-item label="公告内容">
|
||
<el-input v-model="announcementContent" type="textarea" :rows="3" placeholder="输入公告内容..." />
|
||
</el-form-item>
|
||
<el-button type="warning" :loading="announceLoading" @click="handleAnnouncement">推送公告</el-button>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<!-- Daily Check -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<polyline points="12 6 12 12 16 14"></polyline>
|
||
</svg>
|
||
填报检查
|
||
</div>
|
||
</template>
|
||
<div class="setting-row">
|
||
<div class="setting-col">
|
||
<label class="setting-label">定时通报时间</label>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<el-time-select
|
||
v-model="notificationTime"
|
||
start="08:00"
|
||
step="00:05"
|
||
end="21:00"
|
||
placeholder="选择时间"
|
||
format="HH:mm"
|
||
style="width:140px"
|
||
/>
|
||
<el-button type="primary" :loading="notificationTimeLoading" @click="handleSaveNotificationTime" size="small">保存</el-button>
|
||
</div>
|
||
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">每日定时向未填报人员推送催办提醒,并向支局长发送汇总</p>
|
||
</div>
|
||
<div class="setting-col">
|
||
<label class="setting-label">手动触发</label>
|
||
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- Holidays -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||
</svg>
|
||
法定节假日
|
||
</div>
|
||
</template>
|
||
<div class="setting-row">
|
||
<div class="setting-col" style="flex:1">
|
||
<label class="setting-label">节假日日期(逗号分隔,格式 YYYY-MM-DD)</label>
|
||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||
<el-input
|
||
v-model="holidays"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="如: 2026-10-01, 2026-10-02, 2026-10-05"
|
||
style="flex:1"
|
||
/>
|
||
<el-button type="primary" :loading="holidaysLoading" @click="handleSaveHolidays" size="small">保存</el-button>
|
||
</div>
|
||
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">
|
||
数据来源:apisbo.com 中国节假日 API · 周末自动跳过无需配置
|
||
<el-button :loading="holidaysRefreshing" @click="handleRefreshHolidays" size="small" text type="primary" style="margin-left:8px">从 API 刷新</el-button>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- Import -->
|
||
<el-card class="setting-card">
|
||
<template #header>
|
||
<div class="card-header-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
Excel 数据导入
|
||
</div>
|
||
</template>
|
||
<el-form>
|
||
<el-form-item label="上传旧周报 Excel">
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
|
||
<el-button size="small" @click="downloadWeeklyTemplate">📋 下载模板</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">预览并导入</el-button>
|
||
</el-form>
|
||
<div v-if="importPreview" style="margin-top:16px">
|
||
<h4 style="font-family: var(--font-heading); color: var(--ink)">文件预览</h4>
|
||
<el-table :data="Object.entries(importPreview)" stripe>
|
||
<el-table-column prop="0" label="Sheet" />
|
||
<el-table-column prop="1.row_count" label="数据行数" />
|
||
<el-table-column label="表头">
|
||
<template #default="{ row }">{{ (row[1] as any).headers.join(', ') }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div v-if="importResult" style="margin-top:12px">
|
||
<el-alert type="success" :closable="false">
|
||
导入完成:拜访 {{ importResult.visits || 0 }} 条、纪要 {{ importResult.daily_notes || 0 }} 条、计划 {{ importResult.work_plans || 0 }} 条、商机 {{ importResult.mini_business || 0 }} 条、要客 {{ importResult.key_visits || 0 }} 条
|
||
<template v-if="importResult.customers_created">,自动创建客户 {{ importResult.customers_created }} 个</template>
|
||
<template v-if="importResult.external_companions">,外部同访人 {{ importResult.external_companions }} 人</template>
|
||
<template v-if="importResult.skipped">,跳过 {{ importResult.skipped }} 条</template>
|
||
<template v-if="importResult.name_corrections?.length">
|
||
<div style="margin-top:6px; font-size:12px; color:var(--amber)">
|
||
<div v-for="(r, i) in importResult.name_corrections.slice(0, 10)" :key="'nc'+i">🔧 {{ r }}</div>
|
||
</div>
|
||
</template>
|
||
<template v-if="importResult.customers_created_names?.length">
|
||
<div style="margin-top:4px; font-size:12px; color:var(--sage)">
|
||
🆕 新建客户:{{ importResult.customers_created_names.join('、') }}
|
||
</div>
|
||
</template>
|
||
<template v-if="importResult.skip_reasons?.length">
|
||
<div style="margin-top:8px; font-size:12px; max-height:200px; overflow-y:auto">
|
||
<div v-for="(r, i) in importResult.skip_reasons.slice(0, 20)" :key="i">• {{ r }}</div>
|
||
<div v-if="importResult.skip_reasons.length > 20" style="color:var(--c-text-muted)">
|
||
...还有 {{ importResult.skip_reasons.length - 20 }} 条
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-head { margin-bottom: 24px; }
|
||
.page-title {
|
||
margin: 0;
|
||
font-family: var(--font-heading);
|
||
font-size: 22px; font-weight: 400;
|
||
color: var(--ink); letter-spacing: 0.06em;
|
||
}
|
||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||
|
||
.setting-card { margin-bottom: 16px; }
|
||
.card-header-title {
|
||
display: flex; align-items: center;
|
||
font-family: var(--font-heading);
|
||
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
|
||
}
|
||
.setting-row { display: flex; gap: 32px; align-items: flex-start; flex-wrap: wrap; }
|
||
.setting-col { display: flex; flex-direction: column; gap: 8px; }
|
||
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||
|
||
/* ── Theme Selector ── */
|
||
.theme-options { display: flex; gap: 16px; }
|
||
.theme-opt { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 16px; border: 1px solid var(--warm-border); transition: all var(--transition); }
|
||
.theme-opt:hover { border-color: var(--ink); }
|
||
.theme-opt--active { border-color: var(--ink); background: var(--c-primary-bg); }
|
||
.theme-opt-radio { font-size: 14px; color: var(--ink); }
|
||
.theme-opt-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); }
|
||
</style>
|