449 lines
17 KiB
Vue
449 lines
17 KiB
Vue
<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 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('')
|
||
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)
|
||
syncPreparedDatePickerFromString()
|
||
return
|
||
}
|
||
|
||
isProjectInitialized.value = false
|
||
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
|
||
projectName.value = DEFAULT_PROJECT_NAME
|
||
preparedBy.value = ''
|
||
reviewedBy.value = ''
|
||
preparedCompany.value = ''
|
||
preparedDate.value = ''
|
||
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 = ''
|
||
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 = ''
|
||
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="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="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="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="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="h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted"
|
||
>
|
||
关闭
|
||
</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>
|