Files
qiji/frontend/src/views/desktop/Settings.vue
T
v6ole 7dcb22f573 feat: 集成 apisbo.com 中国节假日 API 自动获取+缓存
- 新增 holidays.py 服务:按年获取节假日数据,缓存到 system_config
- 区分 holiday(工作日假日)和 workday(调休上班日)两种类型
- is_working_day() 支持调休判断:周末在 workdays 集合中视为工作日
- 启动时自动从 API 刷新当年+明年数据,失败则使用缓存
- 系统设置新增「从 API 刷新」按钮,支局长可手动触发
- 支持 2010-2026 年数据,API 免费免鉴权

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 14:17:31 +08:00

360 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>