forked from jiaoly/financial_system
更新前端样式
This commit is contained in:
parent
960d2aa028
commit
b70e29563a
313
frontend/src/components/DatePicker.vue
Normal file
313
frontend/src/components/DatePicker.vue
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from "@lucide/vue";
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
mode: "month" | "date";
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: "请选择日期",
|
||||||
|
disabled: false,
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: string): void;
|
||||||
|
(event: "change", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type CalendarCell = {
|
||||||
|
dateValue: string;
|
||||||
|
day: number;
|
||||||
|
muted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 292;
|
||||||
|
const PANEL_OFFSET = 8;
|
||||||
|
const VIEWPORT_GAP = 12;
|
||||||
|
|
||||||
|
const rootRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
|
const open = ref(false);
|
||||||
|
const panelYear = ref(new Date().getFullYear());
|
||||||
|
const panelMonth = ref(new Date().getMonth());
|
||||||
|
|
||||||
|
const monthNames = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
|
||||||
|
const weekNames = ["一", "二", "三", "四", "五", "六", "日"];
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (!props.modelValue) {
|
||||||
|
return props.placeholder;
|
||||||
|
}
|
||||||
|
if (props.mode === "month") {
|
||||||
|
const parsed = parseMonth(props.modelValue);
|
||||||
|
return parsed ? `${parsed.year}年${String(parsed.month + 1).padStart(2, "0")}月` : props.modelValue;
|
||||||
|
}
|
||||||
|
const parsed = parseDate(props.modelValue);
|
||||||
|
return parsed
|
||||||
|
? `${parsed.year}年${String(parsed.month + 1).padStart(2, "0")}月${String(parsed.day).padStart(2, "0")}日`
|
||||||
|
: props.modelValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMonthValue = computed(() => props.modelValue.slice(0, 7));
|
||||||
|
const todayDateValue = computed(() => formatDate(new Date()));
|
||||||
|
const currentMonthValue = computed(() => todayDateValue.value.slice(0, 7));
|
||||||
|
|
||||||
|
const calendarCells = computed<CalendarCell[]>(() => {
|
||||||
|
const cells: CalendarCell[] = [];
|
||||||
|
const firstDay = new Date(panelYear.value, panelMonth.value, 1);
|
||||||
|
const firstOffset = (firstDay.getDay() + 6) % 7;
|
||||||
|
const firstVisible = new Date(panelYear.value, panelMonth.value, 1 - firstOffset);
|
||||||
|
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const date = new Date(firstVisible);
|
||||||
|
date.setDate(firstVisible.getDate() + index);
|
||||||
|
cells.push({
|
||||||
|
dateValue: formatDate(date),
|
||||||
|
day: date.getDate(),
|
||||||
|
muted: date.getMonth() !== panelMonth.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async () => {
|
||||||
|
if (open.value) {
|
||||||
|
syncPanelFromValue();
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPosition();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener("click", handleOutsideClick);
|
||||||
|
window.addEventListener("resize", updatePanelPosition);
|
||||||
|
window.addEventListener("scroll", updatePanelPosition, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("click", handleOutsideClick);
|
||||||
|
window.removeEventListener("resize", updatePanelPosition);
|
||||||
|
window.removeEventListener("scroll", updatePanelPosition, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function togglePanel(): Promise<void> {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open.value) {
|
||||||
|
closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPanelFromValue();
|
||||||
|
open.value = true;
|
||||||
|
await nextTick();
|
||||||
|
updatePanelPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(): void {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(event: MouseEvent): void {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanelPosition(): void {
|
||||||
|
if (!open.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = rootRef.value?.querySelector<HTMLElement>(".date-picker-trigger");
|
||||||
|
if (!trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
const width = Math.min(PANEL_WIDTH, Math.max(220, viewportWidth - VIEWPORT_GAP * 2));
|
||||||
|
const measuredHeight = panelRef.value?.offsetHeight;
|
||||||
|
const panelHeight = measuredHeight || (props.mode === "date" ? 380 : 242);
|
||||||
|
const minLeft = VIEWPORT_GAP;
|
||||||
|
const maxLeft = Math.max(minLeft, viewportWidth - width - VIEWPORT_GAP);
|
||||||
|
const left = clamp(rect.left, minLeft, maxLeft);
|
||||||
|
const belowTop = rect.bottom + PANEL_OFFSET;
|
||||||
|
const aboveTop = rect.top - panelHeight - PANEL_OFFSET;
|
||||||
|
const fitsBelow = belowTop + panelHeight <= viewportHeight - VIEWPORT_GAP;
|
||||||
|
const fitsAbove = aboveTop >= VIEWPORT_GAP;
|
||||||
|
const top = fitsBelow
|
||||||
|
? belowTop
|
||||||
|
: fitsAbove
|
||||||
|
? aboveTop
|
||||||
|
: Math.max(VIEWPORT_GAP, viewportHeight - panelHeight - VIEWPORT_GAP);
|
||||||
|
|
||||||
|
panelStyle.value = {
|
||||||
|
top: `${Math.round(top)}px`,
|
||||||
|
left: `${Math.round(left)}px`,
|
||||||
|
width: `${Math.round(width)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPanelFromValue(): void {
|
||||||
|
const parsed = props.mode === "month" ? parseMonth(props.modelValue) : parseDate(props.modelValue);
|
||||||
|
const fallback = new Date();
|
||||||
|
panelYear.value = parsed?.year ?? fallback.getFullYear();
|
||||||
|
panelMonth.value = parsed?.month ?? fallback.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousPanel(): void {
|
||||||
|
if (props.mode === "month") {
|
||||||
|
panelYear.value -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = new Date(panelYear.value, panelMonth.value - 1, 1);
|
||||||
|
panelYear.value = date.getFullYear();
|
||||||
|
panelMonth.value = date.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPanel(): void {
|
||||||
|
if (props.mode === "month") {
|
||||||
|
panelYear.value += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = new Date(panelYear.value, panelMonth.value + 1, 1);
|
||||||
|
panelYear.value = date.getFullYear();
|
||||||
|
panelMonth.value = date.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMonth(monthIndex: number): void {
|
||||||
|
commitValue(`${panelYear.value}-${String(monthIndex + 1).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(dateValue: string): void {
|
||||||
|
commitValue(dateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCurrent(): void {
|
||||||
|
commitValue(props.mode === "month" ? currentMonthValue.value : todayDateValue.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearValue(): void {
|
||||||
|
commitValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitValue(value: string): void {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value);
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMonth(value: string): { year: number; month: number } | null {
|
||||||
|
const match = /^(\d{4})-(\d{2})$/.exec(value || "");
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const month = Number(match[2]) - 1;
|
||||||
|
if (month < 0 || month > 11) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { year: Number(match[1]), month };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string): { year: number; month: number; day: number } | null {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]) - 1;
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date): string {
|
||||||
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="date-picker" :class="{ 'is-open': open, 'is-disabled': disabled }">
|
||||||
|
<button class="date-picker-trigger" type="button" :disabled="disabled" @click="togglePanel">
|
||||||
|
<span :class="{ muted: !modelValue }">{{ displayValue }}</span>
|
||||||
|
<CalendarDays :size="17" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" ref="panelRef" class="date-picker-panel" :style="panelStyle" role="dialog" aria-label="日期选择">
|
||||||
|
<div class="date-picker-panel-head">
|
||||||
|
<button class="date-picker-nav" type="button" title="上一个" @click="previousPanel">
|
||||||
|
<ChevronLeft :size="17" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<strong>{{ panelYear }}{{ mode === "date" ? `年${String(panelMonth + 1).padStart(2, "0")}月` : "年" }}</strong>
|
||||||
|
<button class="date-picker-nav" type="button" title="下一个" @click="nextPanel">
|
||||||
|
<ChevronRight :size="17" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode === 'month'" class="month-grid">
|
||||||
|
<button
|
||||||
|
v-for="(month, index) in monthNames"
|
||||||
|
:key="month"
|
||||||
|
class="date-picker-cell"
|
||||||
|
:class="{ active: `${panelYear}-${String(index + 1).padStart(2, '0')}` === selectedMonthValue }"
|
||||||
|
type="button"
|
||||||
|
@click="selectMonth(index)"
|
||||||
|
>
|
||||||
|
{{ month }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="calendar-shell">
|
||||||
|
<div class="calendar-week-row">
|
||||||
|
<span v-for="week in weekNames" :key="week">{{ week }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<button
|
||||||
|
v-for="cell in calendarCells"
|
||||||
|
:key="cell.dateValue"
|
||||||
|
class="date-picker-cell calendar-day"
|
||||||
|
:class="{ active: cell.dateValue === modelValue, today: cell.dateValue === todayDateValue, muted: cell.muted }"
|
||||||
|
type="button"
|
||||||
|
@click="selectDate(cell.dateValue)"
|
||||||
|
>
|
||||||
|
{{ cell.day }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-picker-actions">
|
||||||
|
<button v-if="clearable" class="date-picker-text-button" type="button" @click="clearValue">清空</button>
|
||||||
|
<button class="date-picker-text-button primary" type="button" @click="selectCurrent">
|
||||||
|
{{ mode === "month" ? "本月" : "今天" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,176 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
FileClock,
|
|
||||||
Loader2,
|
|
||||||
Search,
|
|
||||||
} from "@lucide/vue";
|
|
||||||
import { onMounted, ref } from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
|
|
||||||
import { ApiError } from "../api/http";
|
|
||||||
import {
|
|
||||||
downloadPayrollFile,
|
|
||||||
getPayrollJob,
|
|
||||||
readRecentJobs,
|
|
||||||
} from "../api/payroll";
|
|
||||||
import type { PayrollJobResponse, RecentPayrollJob } from "../api/types";
|
|
||||||
import ResultsTable from "../components/ResultsTable.vue";
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const queryJobId = ref("");
|
|
||||||
const loading = ref(false);
|
|
||||||
const errorMessage = ref("");
|
|
||||||
const selectedJob = ref<PayrollJobResponse | null>(null);
|
|
||||||
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs());
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const jobId = typeof route.query.job_id === "string" ? route.query.job_id : "";
|
|
||||||
if (jobId) {
|
|
||||||
queryJobId.value = jobId;
|
|
||||||
searchJob();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function searchJob(): Promise<void> {
|
|
||||||
const jobId = queryJobId.value.trim();
|
|
||||||
if (!jobId) {
|
|
||||||
errorMessage.value = "请输入任务编号";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
errorMessage.value = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
selectedJob.value = await getPayrollJob(jobId);
|
|
||||||
router.replace({ path: "/payroll/jobs", query: { job_id: jobId } });
|
|
||||||
} catch (error) {
|
|
||||||
selectedJob.value = null;
|
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "任务查询失败";
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseRecent(job: RecentPayrollJob): void {
|
|
||||||
queryJobId.value = job.job_id;
|
|
||||||
searchJob();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadCurrent(): Promise<void> {
|
|
||||||
if (!selectedJob.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await downloadPayrollFile(null, selectedJob.value.output_file);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadRecent(job: RecentPayrollJob): Promise<void> {
|
|
||||||
await downloadPayrollFile(job.download_url, job.output_file);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="view-stack">
|
|
||||||
<header class="page-header">
|
|
||||||
<div>
|
|
||||||
<h1>计算记录</h1>
|
|
||||||
<p>按任务编号查询已落库的工资计算结果。</p>
|
|
||||||
</div>
|
|
||||||
<button class="primary-button" type="button" :disabled="!selectedJob?.output_file" @click="downloadCurrent">
|
|
||||||
<Download :size="18" aria-hidden="true" />
|
|
||||||
<span>下载当前结果</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>任务查询</h2>
|
|
||||||
<p>工资计算任务 ID</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form class="search-form" @submit.prevent="searchJob">
|
|
||||||
<label class="field">
|
|
||||||
<span>任务编号</span>
|
|
||||||
<input v-model="queryJobId" type="text" placeholder="例如 8c21f..." />
|
|
||||||
</label>
|
|
||||||
<button class="primary-button" type="submit" :disabled="loading">
|
|
||||||
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
|
||||||
<Search v-else :size="18" aria-hidden="true" />
|
|
||||||
<span>{{ loading ? "查询中" : "查询" }}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="selectedJob" class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>任务详情</h2>
|
|
||||||
<p>{{ selectedJob.job_id }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="status-chip" :class="selectedJob.status === 'completed' ? 'green' : 'neutral'">
|
|
||||||
{{ selectedJob.status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<dl class="detail-grid">
|
|
||||||
<div>
|
|
||||||
<dt>任务来源</dt>
|
|
||||||
<dd>{{ selectedJob.source_type }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>员工数量</dt>
|
|
||||||
<dd>{{ selectedJob.employee_count }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>创建时间</dt>
|
|
||||||
<dd>{{ new Date(selectedJob.created_at).toLocaleString() }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>更新时间</dt>
|
|
||||||
<dd>{{ new Date(selectedJob.updated_at).toLocaleString() }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p v-if="selectedJob.error_message" class="form-error">{{ selectedJob.error_message }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ResultsTable v-if="selectedJob" :results="selectedJob.results" />
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>最近任务</h2>
|
|
||||||
<p>当前浏览器记录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="recent-list">
|
|
||||||
<button v-for="job in recentJobs" :key="job.job_id" class="recent-item" type="button" @click="chooseRecent(job)">
|
|
||||||
<FileClock :size="20" aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
<strong>{{ job.job_id }}</strong>
|
|
||||||
<em>{{ job.source_type }} · {{ job.employee_count }} 人</em>
|
|
||||||
</span>
|
|
||||||
<small>{{ new Date(job.created_at).toLocaleDateString() }}</small>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="!recentJobs.length" class="empty-state">
|
|
||||||
<p>暂无最近任务</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="recentJobs.length" class="form-actions align-right">
|
|
||||||
<button
|
|
||||||
v-for="job in recentJobs.slice(0, 3)"
|
|
||||||
:key="`download-${job.job_id}`"
|
|
||||||
class="secondary-button"
|
|
||||||
type="button"
|
|
||||||
@click="downloadRecent(job)"
|
|
||||||
>
|
|
||||||
<Download :size="17" aria-hidden="true" />
|
|
||||||
<span>{{ job.job_id.slice(0, 8) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
Loading…
Reference in New Issue
Block a user