This commit is contained in:
wintsa 2026-03-27 09:55:08 +08:00
parent b1728bbc47
commit bbcd07a595
5 changed files with 207 additions and 116 deletions

View File

@ -18,7 +18,7 @@ import {
} from '@/lib/workspace'
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
import { listProjects, type ProjectMeta } from '@/lib/projectRegistry'
const tabStore = useTabStore()
const { t } = useI18n()
@ -130,9 +130,8 @@ const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean
}
const createProjectAndOpen = () => {
const project = createProject(t('xmInfo.defaultProjectName'))
refreshConflictProjectList()
openProjectInNewTab(project.id, { newProject: true })
openProjectInNewTab(DEFAULT_PROJECT_ID, { newProject: true })
}
const syncRouteRequestFlags = () => {

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@ -47,6 +47,7 @@ import {
} from '@/lib/workspace'
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
import { createProjectKvAdapter } from '@/lib/projectKvStore'
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
interface QuickProjectInfoState {
projectIndustry?: string
@ -75,7 +76,6 @@ interface ProjectInfoState {
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const getActiveProjectId = () => readCurrentProjectId()
const tabStore = useTabStore()
@ -96,6 +96,8 @@ const existingProjectDialogOpen = ref(false)
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
const existingProjectLoading = ref(false)
const hasExistingProjects = ref(false)
const openedProjectIds = ref<string[]>([])
let existingProjectPollTimer: ReturnType<typeof setInterval> | null = null
const projectIndustryLabel = computed(() => {
const target = String(projectIndustry.value || '').trim()
if (!target) return ''
@ -175,8 +177,19 @@ const openProjectCalc = async () => {
projectDialogOpen.value = true
}
const refreshExistingProjects = async () => {
const syncExistingProjectOpenedState = (projectIds: string[]) => {
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(projectIds))
}
const isExistingProjectOpened = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
return projectId ? openedProjectIds.value.includes(projectId) : false
}
const refreshExistingProjects = async (options?: { showLoading?: boolean }) => {
if (options?.showLoading !== false) {
existingProjectLoading.value = true
}
try {
const projects = listProjects()
.filter(item => item.id !== QUICK_PROJECT_ID)
@ -191,23 +204,42 @@ const refreshExistingProjects = async () => {
updatedAt: project.updatedAt
}))
hasExistingProjects.value = projects.length > 0
syncExistingProjectOpenedState(projects.map(project => project.id))
} finally {
if (options?.showLoading !== false) {
existingProjectLoading.value = false
}
}
}
const stopExistingProjectPolling = () => {
if (existingProjectPollTimer == null) return
clearInterval(existingProjectPollTimer)
existingProjectPollTimer = null
}
const startExistingProjectPolling = () => {
stopExistingProjectPolling()
existingProjectPollTimer = setInterval(() => {
if (!existingProjectDialogOpen.value) return
void refreshExistingProjects({ showLoading: false })
}, 3000)
}
const openExistingProjectDialog = async () => {
existingProjectDialogOpen.value = true
await refreshExistingProjects()
startExistingProjectPolling()
}
const closeExistingProjectDialog = () => {
existingProjectDialogOpen.value = false
stopExistingProjectPolling()
}
const enterExistingProject = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
if (!projectId) return
if (!projectId || isExistingProjectOpened(projectId)) return
upsertProject(projectId, resolveProjectRegistryName(projectId))
if (!navigateToWorkspace(projectId, 'project')) return
tabStore.enterWorkspace({
@ -229,8 +261,6 @@ const confirmProjectCalc = async () => {
projectSubmitting.value = true
try {
const activeProjectId = getActiveProjectId()
if (activeProjectId === DEFAULT_PROJECT_ID) {
const project = createProject(t('xmInfo.defaultProjectName'))
const kvAdapter = createProjectKvAdapter(project.id)
await kvAdapter.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
@ -249,24 +279,6 @@ const confirmProjectCalc = async () => {
)
writeWorkspaceMode('project')
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
return
}
await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
projectIndustry: industry,
projectName: t('xmInfo.defaultProjectName'),
preparedBy: '',
reviewedBy: '',
preparedCompany: '',
preparedDate: getTodayDateString()
})
await initializeProjectFactorStates(
kvStore,
industry,
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
PROJECT_MAJOR_FACTOR_KEY
)
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
enterProjectCalc()
} finally {
projectSubmitting.value = false
projectDialogOpen.value = false
@ -369,10 +381,22 @@ const confirmHomeImport = async () => {
cancelHomeImportConfirm()
}
const handleHomeWindowFocus = () => {
if (!existingProjectDialogOpen.value) return
void refreshExistingProjects({ showLoading: false })
}
const handleHomeVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
handleHomeWindowFocus()
}
onMounted(() => {
void refreshExistingProjects()
void loadProjectDefaults()
void loadQuickDefaults()
window.addEventListener('focus', handleHomeWindowFocus)
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
try {
const url = new URL(window.location.href)
const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
@ -392,6 +416,12 @@ onMounted(() => {
// ignore url parsing errors
}
})
onBeforeUnmount(() => {
stopExistingProjectPolling()
window.removeEventListener('focus', handleHomeWindowFocus)
document.removeEventListener('visibilitychange', handleHomeVisibilityChange)
})
</script>
<template>
@ -580,11 +610,20 @@ onMounted(() => {
v-for="project in existingProjects"
:key="project.id"
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
:disabled="isExistingProjectOpened(project.id)"
class="flex w-full items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left transition"
:class="isExistingProjectOpened(project.id)
? 'cursor-not-allowed border-slate-200 bg-slate-100/80 opacity-70'
: 'cursor-pointer hover:border-slate-300 hover:bg-slate-50'"
@click="enterExistingProject(project.id)"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-800">{{ project.name }}</div>
<div class="truncate text-sm font-medium text-slate-800">
{{ project.name }}
<span v-if="isExistingProjectOpened(project.id)" class="ml-1 text-xs text-slate-500">
{{ t('tab.toolbar.opened') }}
</span>
</div>
<div class="mt-0.5 text-xs text-slate-500">{{ project.id }}</div>
</div>
<div class="shrink-0 pl-2 text-xs text-slate-500">

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ChevronDown, Circle, CircleDot } from 'lucide-vue-next'
import { Circle, CircleDot } from 'lucide-vue-next'
import {
getQuickCalcGroups,
getMajorDictItemById,
@ -17,18 +17,6 @@ import { getIndustryMajorEntry } from '@/lib/pricingScaleCalc'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
import { QUICK_PROJECT_INFO_KEY } from '@/lib/workspace'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import {
SelectContent,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
const props = defineProps<{
contractId: string
@ -140,6 +128,15 @@ const industryLabel = computed(() => {
return getIndustryDisplayName(target, locale.value) || t('quickCalc.notSelected')
})
const isIndustrySelected = (industryId: string | number) => {
return projectIndustry.value.trim() === String(industryId).trim()
}
const handleIndustrySelect = (industryId: string | number) => {
const nextIndustry = String(industryId).trim()
projectIndustry.value = isIndustrySelected(nextIndustry) ? '' : nextIndustry
}
const selectedConsultOption = computed(() =>
quickCalcGroups
.find(item => item.key === 'consult')
@ -446,41 +443,44 @@ watch(canUseLandScale, enabled => {
<div class="quick-calc-toolbar">
<label class="quick-calc-toolbar__field">
<label class="quick-calc-toolbar__field quick-calc-toolbar__field--cards">
<span class="quick-calc-field__label">{{ t('quickCalc.fields.industry') }}</span>
<SelectRoot v-model="projectIndustry">
<SelectTrigger class="quick-calc-toolbar__trigger">
<SelectValue :placeholder="t('quickCalc.selectIndustry')" />
<SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-[var(--qc-muted)]" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
:side-offset="6"
position="popper"
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
>
<SelectViewport class="p-1">
<SelectItem
<div class="quick-calc-industry-grid" role="radiogroup" :aria-label="t('quickCalc.fields.industry')">
<label
v-for="item in industryTypeList"
:key="`quick-workbench-${item.id}`"
:value="String(item.id)"
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
class="quick-calc-industry-card"
:class="{ 'is-selected': isIndustrySelected(item.id) }"
:aria-checked="isIndustrySelected(item.id)"
role="radio"
tabindex="0"
@click.prevent="handleIndustrySelect(item.id)"
@keydown.enter.prevent="handleIndustrySelect(item.id)"
@keydown.space.prevent="handleIndustrySelect(item.id)"
>
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
<input
:checked="isIndustrySelected(item.id)"
type="radio"
name="quick-calc-industry-choice"
class="quick-calc-option__input"
tabindex="-1"
>
<span
class="quick-calc-industry-card__icon"
:class="{ 'is-selected': isIndustrySelected(item.id) }"
>
<CircleDot v-if="isIndustrySelected(item.id)" class="h-3.5 w-3.5" />
<Circle v-else class="h-3.5 w-3.5" />
</span>
<span class="quick-calc-industry-card__text">
{{ getIndustryDisplayName(item.id, locale) }}
</span>
</label>
</div>
</label>
<span class="quick-calc-toolbar__meta">
{{ industrySaving ? t('quickCalc.saving') : hasSelectedIndustry ? t('quickCalc.synced') : t('quickCalc.notSelectedIndustry') }}
{{ industrySaving ? t('quickCalc.saving') : industryLabel }}
</span>
</div>
@ -739,30 +739,76 @@ watch(canUseLandScale, enabled => {
flex: 1;
}
.quick-calc-toolbar__trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid var(--qc-border);
border-radius: 12px;
background: color-mix(in srgb, white 55%, var(--qc-surface));
color: var(--qc-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 80%, transparent);
outline: none;
.quick-calc-toolbar__field--cards {
align-content: start;
}
.quick-calc-toolbar__trigger:focus-visible {
.quick-calc-industry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
gap: 8px;
}
.quick-calc-industry-card {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--qc-border);
border-radius: 12px;
background: color-mix(in srgb, var(--background) 92%, var(--card));
color: var(--qc-text);
cursor: pointer;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.quick-calc-industry-card:hover {
transform: translateY(-1px);
border-color: var(--qc-border-strong);
box-shadow: 0 0 0 3px color-mix(in srgb, hsl(var(--destructive)) 14%, transparent);
}
.quick-calc-industry-card:focus-visible {
outline: none;
border-color: color-mix(in srgb, hsl(var(--destructive)) 42%, var(--qc-border));
box-shadow: 0 0 0 3px color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
}
.quick-calc-industry-card.is-selected {
border-color: color-mix(in srgb, hsl(var(--destructive)) 42%, var(--qc-border));
background: color-mix(in srgb, hsl(var(--destructive)) 7%, white);
box-shadow: 0 0 0 3px color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
}
.quick-calc-industry-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: 0 0 14px;
color: color-mix(in srgb, var(--foreground) 44%, transparent);
transition: color 140ms ease, transform 140ms ease;
}
.quick-calc-industry-card__icon.is-selected {
color: hsl(var(--destructive));
transform: scale(1.02);
}
.quick-calc-industry-card__text {
min-width: 0;
font-size: 14px;
line-height: 1.3;
}
.quick-calc-toolbar__meta {
flex-shrink: 0;
font-size: 12px;
color: var(--qc-muted);
text-align: right;
}
.quick-calc-empty-state {
@ -1100,22 +1146,23 @@ watch(canUseLandScale, enabled => {
}
.quick-calc-panel--form .quick-calc-panel__title {
font-size: clamp(0.88rem, 0.94vw, 1rem);
font-size: 14px;
}
.quick-calc-panel--form .quick-calc-panel__eyebrow {
font-size: 9px;
font-size: 14px;
}
.quick-calc-panel--form .quick-calc-status__item {
min-height: 20px;
padding: 0 6px;
font-size: 9px;
font-size: 14px;
}
.quick-calc-panel--form .quick-calc-form {
overflow: auto;
padding: 8px;
font-size: 14px;
}
.quick-calc-panel--form .quick-calc-form-stack {
@ -1166,11 +1213,13 @@ watch(canUseLandScale, enabled => {
}
.quick-calc-panel--form .quick-calc-form-section__eyebrow {
font-size: 9px;
font-size: 14px;
letter-spacing: 0;
text-transform: none;
}
.quick-calc-panel--form .quick-calc-form-section__title {
font-size: 12px;
font-size: 14px;
}
.quick-calc-form-grid {
@ -1222,14 +1271,17 @@ watch(canUseLandScale, enabled => {
}
.quick-calc-panel--form .quick-calc-field__label {
font-size: 9px;
font-size: 14px;
letter-spacing: 0;
text-transform: none;
font-weight: 600;
}
.quick-calc-panel--form .quick-calc-field__input,
.quick-calc-panel--form .quick-calc-field__readonly {
min-height: 32px;
padding: 0 8px;
font-size: 12px;
font-size: 14px;
border-radius: 9px;
}
@ -1288,8 +1340,8 @@ watch(canUseLandScale, enabled => {
}
.quick-calc-panel--form .quick-calc-form-hint {
font-size: 9px;
line-height: 1.15;
font-size: 14px;
line-height: 1.35;
}
.quick-calc-segment {
@ -1325,6 +1377,15 @@ watch(canUseLandScale, enabled => {
padding: 8px;
}
.quick-calc-toolbar {
align-items: stretch;
flex-direction: column;
}
.quick-calc-toolbar__meta {
text-align: left;
}
.quick-calc-layout {
grid-template-columns: 1fr;
}

View File

@ -67,8 +67,8 @@ export const zhCN = {
projectList: '项目列表',
projectCount: '项目数量:{count}',
createProject: '新建项目',
backHome: '返回首页',
resetAll: '重置全部项目',
backHome: '返回入口',
resetAll: '清除全部项目',
opened: '(已打开)',
lastEdited: '最后编辑:{time}'
},

View File

@ -1169,11 +1169,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows)
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost))
projectScale.push({
majorid: -1,
major: -1, cost: projectScaleCost,
area: null
})
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows)
@ -1374,11 +1370,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const reserveFee = toMoney(reserve ? reserve.fee : 0)
const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee))
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
contractScale.push({
majorid: -1,
major: -1, cost: contractFee,
area: null
})
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorState.resolved?.detailRows)
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorState.resolved?.detailRows)
console.log('[export][contract factor rows]', {