更新前端样式

This commit is contained in:
焦龙言 2026-06-29 15:00:17 +08:00
parent 960d2aa028
commit b70e29563a
2 changed files with 313 additions and 176 deletions

View 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>

View File

@ -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>