2026-03-07 11:47:07 +08:00

457 lines
17 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 { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { industryTypeList } from '@/sql'
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import {
DatePickerAnchor,
DatePickerArrow,
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
DatePickerHeader,
DatePickerHeading,
DatePickerInput,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger
} from 'reka-ui'
interface XmInfoState {
projectIndustry?: string
projectName?: string
preparedBy?: string
reviewedBy?: string
preparedCompany?: string
preparedDate?: string
}
type MajorParentNode = { id: string; name: string }
const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const getTodayDateString = () => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isProjectInitialized = ref(false)
const showCreateDialog = ref(false)
const pendingIndustry = ref('')
const projectName = ref('')
const projectIndustry = ref('')
const preparedBy = ref('')
const reviewedBy = ref('')
const preparedCompany = ref('')
const preparedDate = ref(getTodayDateString())
const preparedDatePickerValue = ref<any>(undefined)
const normalizeDateString = (value: unknown): string => {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
try {
const parsed = parseDate(trimmed)
return parsed.toString() === trimmed ? trimmed : ''
} catch {
return ''
}
}
const syncPreparedDatePickerFromString = () => {
if (!preparedDate.value) {
preparedDatePickerValue.value = undefined
return
}
try {
preparedDatePickerValue.value = parseDate(preparedDate.value)
} catch {
preparedDatePickerValue.value = undefined
preparedDate.value = ''
}
}
const handlePreparedDateSelect = (date: any) => {
preparedDatePickerValue.value = date
preparedDate.value = date?.toString() ?? ''
}
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
id: item.id,
name: item.name
}))
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
projectIndustry: projectIndustry.value,
projectName: projectName.value,
preparedBy: preparedBy.value,
reviewedBy: reviewedBy.value,
preparedCompany: preparedCompany.value,
preparedDate: preparedDate.value
}
await localforage.setItem(DB_KEY, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
isProjectInitialized.value = true
projectIndustry.value =
typeof data.projectIndustry === 'string' && majorParentCodeSet.has(data.projectIndustry)
? data.projectIndustry
: DEFAULT_PROJECT_INDUSTRY
projectName.value =
typeof data.projectName === 'string' && data.projectName.trim() ? data.projectName : DEFAULT_PROJECT_NAME
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
syncPreparedDatePickerFromString()
return
}
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (!isProjectInitialized.value) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
const handleProjectNameBlur = () => {
if (!projectName.value.trim()) {
projectName.value = DEFAULT_PROJECT_NAME
}
}
const openCreateDialog = () => {
pendingIndustry.value = DEFAULT_PROJECT_INDUSTRY
showCreateDialog.value = true
}
const closeCreateDialog = () => {
showCreateDialog.value = false
}
const createProject = async () => {
const selectedIndustry = majorParentCodeSet.has(pendingIndustry.value)
? pendingIndustry.value
: DEFAULT_PROJECT_INDUSTRY
projectIndustry.value = selectedIndustry
projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = ''
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString()
isProjectInitialized.value = true
showCreateDialog.value = false
await saveToIndexedDB()
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
}
watch(
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate],
schedulePersist
)
onMounted(async () => {
await loadFromIndexedDB()
})
</script>
<template>
<TooltipProvider>
<div class="space-y-6 h-full">
<div
v-if="!isProjectInitialized"
class=" bg-card p-10 h-full flex items-center justify-center"
>
<button
type="button"
class="cursor-pointer h-10 rounded-lg bg-primary px-6 text-sm font-medium text-primary-foreground transition hover:opacity-90"
@click="openCreateDialog"
>
新建项目
</button>
</div>
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目名称</label>
<input
v-model="projectName"
type="text"
required
placeholder="xxx造价咨询服务"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
@blur="handleProjectNameBlur"
/>
</div>
<div class="md:col-span-2 xl:col-span-4">
<div class="flex items-center gap-1.5">
<label class="block text-sm font-medium text-foreground">工程行业</label>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
aria-label="工程行业提示"
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground"
>
<CircleHelp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ INDUSTRY_HINT_TEXT }}</TooltipContent>
</TooltipRoot>
</div>
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
<label
v-for="item in majorParentNodes"
:key="item.id"
class="inline-flex items-center gap-2 text-sm text-foreground/80"
>
<input
v-model="projectIndustry"
type="radio"
:value="item.id"
disabled
class="h-4 w-4 cursor-not-allowed accent-primary"
/>
<span>{{ item.name }}</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制人</label>
<input
v-model="preparedBy"
type="text"
placeholder="请输入编制人"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">复核人</label>
<input
v-model="reviewedBy"
type="text"
placeholder="请输入复核人"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制单位</label>
<input
v-model="preparedCompany"
type="text"
placeholder="请输入编制单位"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground">编制日期</label>
<DatePickerRoot
locale="en-CA"
:model-value="preparedDatePickerValue"
@update:model-value="handlePreparedDateSelect"
>
<DatePickerAnchor class="mt-2 block w-full">
<DatePickerField
v-slot="{ segments }"
class="flex h-10 w-full items-center justify-between rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-ring"
>
<div class="flex items-center">
<template
v-for="(segment, index) in segments"
:key="`${segment.part}-${index}`"
>
<DatePickerInput
:part="segment.part"
:class="segment.part === 'literal' ? 'text-muted-foreground/70' : 'text-foreground'"
>
{{ segment.part === 'literal' ? '-' : segment.value }}
</DatePickerInput>
</template>
</div>
<DatePickerTrigger as-child>
<button type="button" class="cursor-pointer inline-flex h-6 w-6 items-center justify-center text-muted-foreground">
<CalendarIcon class="h-4 w-4" />
</button>
</DatePickerTrigger>
</DatePickerField>
</DatePickerAnchor>
<DatePickerContent class="z-50 w-[22rem] rounded-lg border bg-card p-4 shadow-lg">
<DatePickerArrow class="fill-border" />
<DatePickerCalendar v-slot="{ weekDays, grid }" locale="zh-CN">
<DatePickerHeader class="mb-2 flex items-center justify-between">
<DatePickerPrev as-child>
<button
type="button"
class=" cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
</button>
</DatePickerPrev>
<DatePickerHeading class="text-base font-medium text-foreground" />
<DatePickerNext as-child>
<button
type="button"
class="cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
</button>
</DatePickerNext>
</DatePickerHeader>
<div class="space-y-2">
<DatePickerGrid
v-for="month in grid"
:key="month.value.toString()"
class="w-full border-collapse select-none"
>
<DatePickerGridHead>
<DatePickerGridRow class="grid grid-cols-7">
<DatePickerHeadCell
v-for="day in weekDays"
:key="day"
class="flex h-9 items-center justify-center text-sm text-muted-foreground"
>
{{ day }}
</DatePickerHeadCell>
</DatePickerGridRow>
</DatePickerGridHead>
<DatePickerGridBody>
<DatePickerGridRow
v-for="(weekDates, index) in month.rows"
:key="`${month.value.toString()}-${index}`"
class="grid grid-cols-7"
>
<DatePickerCell
v-for="dateValue in weekDates"
:key="dateValue.toString()"
:date="dateValue"
class="h-10 w-full p-0.5"
>
<DatePickerCellTrigger
:day="dateValue"
:month="month.value"
class="cursor-pointer h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40"
>
{{ dateValue.day }}
</DatePickerCellTrigger>
</DatePickerCell>
</DatePickerGridRow>
</DatePickerGridBody>
</DatePickerGrid>
</div>
</DatePickerCalendar>
<div class="mt-2 flex justify-end">
<DatePickerClose as-child>
<button
type="button"
class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
>
确认
</button>
</DatePickerClose>
</div>
</DatePickerContent>
</DatePickerRoot>
</div>
</div>
</div>
<div
v-if="showCreateDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
@click.self="closeCreateDialog"
>
<div class="w-full max-w-lg rounded-xl border bg-card p-5 shadow-lg">
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
<p class="mt-1 text-sm text-muted-foreground">请选择工程行业</p>
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
<label
v-for="item in majorParentNodes"
:key="item.id"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/60"
>
<input v-model="pendingIndustry" type="radio" :value="item.id" class="h-4 w-4 accent-primary" />
<span class="text-sm text-foreground"> {{ item.name }}</span>
</label>
</div>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="cursor-pointer h-9 rounded-lg border px-4 text-sm text-foreground transition hover:bg-muted"
@click="closeCreateDialog"
>
取消
</button>
<button
type="button"
class="cursor-pointer h-9 rounded-lg bg-primary px-4 text-sm text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!pendingIndustry"
@click="createProject"
>
确定
</button>
</div>
</div>
</div>
</div>
</TooltipProvider>
</template>