恢复修改的数据

This commit is contained in:
wangzzpeng
2026-09-28 10:47:49 +08:00
parent f1e64ef864
commit 43c2dc0d8d
57 changed files with 4557 additions and 1642 deletions

View File

@@ -1,41 +1,197 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { BusinessType, PageResult } from '@/types/api'
import type { Case, CaseQuery } from '@/types/business/cases'
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases'
import type {
BusinessIndustryOption,
Case,
CaseQuery,
CaseTypeOption,
ProjectCaseStatisticsVo,
ProjectCaseUserBo,
ScaleLimit,
} from '@/types/business/cases'
import { collectCase, getCaseCategories, getCaseDetail, getCaseList } from '@/api/cases'
import { toCase } from '@/utils/caseVo'
import type { CaseTypeNameMap } from '@/utils/caseVo'
/**
* 案例库数据(列表 / 筛选 / 分页 / 详情 / 收藏)
*
* 后端接口(ProjectCaseUserController,前缀 /web/projectCase):
* - GET /list 列表(分页 + 跟随筛选条件的统计)
* - GET /categories 筛选面板选项(业务类型统计 + 所属行业 + 项目规模档位)
* - GET /{id} 详情(含项目背景富文本与附件,并回填 isFavorite)
* - POST /collect 收藏 / 取消收藏(toggle)
*
* ⚠️ 筛选面板**除完成时间外**的选项全部由后端下发,且**都来自 GET /categories**(一次请求):
* - 业务类型:projectCaseStatisticsVo.projectCaseCounts(按项目提报表补全类型全集)
* - 案例总数:projectCaseStatisticsVo.total
* - 所属行业:businessIndustry
* - 项目规模:upperAndLowerLimits(label 已由后端拼好)
* 完成时间后端无对应选项接口,由前端按快捷区间 + 日期选择器生成。
*
* ⚠️ 业务类型**下发的是提报主键 id**(不是类型名):案例表 business_type 列实测存的就是
* 该 id,传类型名后端按原值匹配不到(实测 total=0)。同一份 id→名称 字典还用于把列表 /
* 详情里的 id 翻译成展示名(见 typeNameById),否则标签会全部退化成「其他」。
*
* 后端 VO 与页面字段的差异(命名、业务类型取值、富文本、数值单位)由 utils/caseVo 统一对齐。
*/
/** 每页条数(案例库为三列卡片栅格,9 = 3 行) */
const PAGE_SIZE = 9
/** 结果数预探测的防抖时长(筛选面板改动后即时回显「N 个结果」) */
const PROBE_DELAY = 300
function createDefaultQuery(): CaseQuery {
return {
category: 'all',
categories: [],
industries: [],
scales: [],
dateRange: null,
favoriteOnly: false,
keyword: '',
page: 1,
pageSize: 8,
pageSize: PAGE_SIZE,
}
}
/** 案例库数据(列表 / 筛选 / 分页 / 收藏) */
/** 筛选面板给的是 yyyy-MM-dd,后端 Date 入参按 yyyy-MM-dd HH:mm:ss 补全 */
function withTime(date: string, time: string): string {
return date.length === 10 ? `${date} ${time}` : date
}
function trimmed(raw: string | null | undefined): string {
return (raw ?? '').trim()
}
/**
* 业务类型统计 → 筛选面板选项。
*
* <p>value 取**提报主键 id**(下发 businessTypes 用),label 取业务类型名称(展示用)。
* 不按前端 BusinessType 枚举归一化,选项一律用后端下发的原值。</p>
*/
function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[] {
return (stats?.projectCaseCounts ?? [])
.map((item) => ({
value: String(item.id ?? '').trim(),
label: trimmed(item.businessType),
count: item.caseCount ?? 0,
}))
.filter((item) => item.value.length > 0 && item.label.length > 0)
}
/** 业务类型统计 → 「提报 id → 类型名」字典(供 utils/caseVo 翻译列表/详情的 business_type) */
function toTypeNameMap(stats: ProjectCaseStatisticsVo | null): CaseTypeNameMap {
const map: CaseTypeNameMap = {}
for (const item of stats?.projectCaseCounts ?? []) {
const id = String(item.id ?? '').trim()
const name = trimmed(item.businessType)
if (id.length > 0 && name.length > 0) map[id] = name
}
return map
}
export const useCasesStore = defineStore('cases', () => {
const query = ref<CaseQuery>(createDefaultQuery())
const list = ref<Case[]>([])
const total = ref(0)
const counts = ref<Partial<Record<BusinessType, number>>>({})
/** 待提交条件的结果数(筛选面板改动后即时探测,仅用于「确定筛选(N 个结果)」) */
const previewTotal = ref(0)
/** 业务类型筛选项(接口下发:GET /categories 的统计;value=提报 id) */
const typeOptions = ref<CaseTypeOption[]>([])
/** 业务类型字典:提报 id → 类型名(列表/详情标签翻译 + 首页精选共用) */
const typeNameById = ref<CaseTypeNameMap>({})
/** 所属行业筛选项(接口下发:GET /categories) */
const industryOptions = ref<BusinessIndustryOption[]>([])
/** 项目规模筛选项(接口下发:GET /categories,label 由后端拼好) */
const scaleOptions = ref<ScaleLimit[]>([])
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
const caseTotal = ref(0)
const currentCase = ref<Case | null>(null)
let probeTimer: ReturnType<typeof setTimeout> | null = null
/** 页面筛选条件 → 后端查询条件(值均由接口下发,原样回传) */
function buildQuery(input: CaseQuery): {
bo: ProjectCaseUserBo
pageNum: number
pageSize: number
} {
const bo: ProjectCaseUserBo = {}
if (input.categories.length > 0) {
// categories 存的已是提报主键 id(CaseTypeOption.value),直接下发
bo.businessTypes = [...input.categories]
}
if (input.industries.length > 0) {
bo.businessIndustries = [...input.industries]
}
if (input.scales.length > 0) {
// 整档对象下发(后端接收 List<UpperAndLowerLimitsModel>),浅拷贝避免把响应式对象传出去
bo.scales = input.scales.map((item) => ({ ...item }))
}
if (input.dateRange) {
bo.beginTime = withTime(input.dateRange[0], '00:00:00')
bo.endTime = withTime(input.dateRange[1], '23:59:59')
}
if (input.favoriteOnly) bo.isFavorite = 1
const keyword = input.keyword.trim()
if (keyword) bo.key = keyword
return { bo, pageNum: input.page, pageSize: input.pageSize }
}
/**
* 筛选面板选项:一次 GET /categories 取回三组 + 业务类型字典。
*
* <p>后端 CaseSelectionModel 里同时带 projectCaseStatisticsVo,所以业务类型选项与案例总数
* 无需再单独打一次 /list —— 这里的所有选项都是「全量口径」,不随当前筛选条件收缩。</p>
*/
async function fetchCategories(): Promise<void> {
const result = await getCaseCategories()
const stats = result.projectCaseStatisticsVo
typeOptions.value = toTypeOptions(stats)
typeNameById.value = toTypeNameMap(stats)
caseTotal.value = stats?.total ?? 0
industryOptions.value = (result.businessIndustry ?? []).filter((item) => trimmed(item.name).length > 0)
scaleOptions.value = result.upperAndLowerLimits ?? []
}
/**
* 确保业务类型字典已就绪(幂等)。
*
* <p>首页「案例库精选」的标签翻译同样依赖该字典 —— 从首页直接进入时,
* 案例库页自身的 fetchCategories 还没跑过,故由调用方(首页 store)显式确保。</p>
*/
async function ensureCategories(): Promise<void> {
if (typeOptions.value.length === 0) await fetchCategories()
}
/** 列表取数 */
async function fetchCases(): Promise<void> {
const result: PageResult<Case> = await getCaseList(query.value)
list.value = result.list
total.value = result.total
const { bo, pageNum, pageSize } = buildQuery(query.value)
const result = await getCaseList(bo, pageNum, pageSize)
list.value = (result.projectCasePage?.rows ?? []).map((vo) => toCase(vo, typeNameById.value))
total.value = result.projectCasePage?.total ?? 0
previewTotal.value = total.value
}
async function fetchCounts(): Promise<void> {
counts.value = await getCaseCounts()
/** 待提交条件的结果数(防抖,避免每次点击都打接口) */
async function probeTotal(): Promise<void> {
const { bo } = buildQuery(query.value)
const result = await getCaseList(bo, 1, 1)
previewTotal.value = result.projectCasePage?.total ?? 0
}
function scheduleProbe(): void {
if (probeTimer) clearTimeout(probeTimer)
probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY)
}
/** 面板改条件(只改待提交条件,不取数;取数由「确定筛选」触发) */
function setQuery(next: CaseQuery): void {
query.value = next
scheduleProbe()
}
/** 确定筛选(重置页码后取数) */
@@ -55,29 +211,61 @@ export const useCasesStore = defineStore('cases', () => {
await fetchCases()
}
async function fetchCaseDetail(id: string): Promise<void> {
currentCase.value = await getCaseDetail(id)
/** 「收藏列表」开关:只筛已收藏案例 */
async function toggleFavoriteOnly(): Promise<void> {
query.value = { ...query.value, favoriteOnly: !query.value.favoriteOnly, page: 1 }
await fetchCases()
}
/** 案例详情(后端查不到时返回 null,页面据此展示空态) */
async function fetchCaseDetail(id: string): Promise<void> {
const vo = await getCaseDetail(id)
currentCase.value = vo ? toCase(vo, typeNameById.value) : null
}
/**
* 收藏 / 取消收藏。
*
* <p>后端为 toggle 语义且不返回最新状态,这里按调用成功的结果本地翻转,
* 避免再调一次详情接口(该接口会自增预览量)。</p>
*/
async function toggleFavorite(id: string): Promise<void> {
const updated = await toggleFavoriteApi(id)
await collectCase(id)
const index = list.value.findIndex((item) => item.id === id)
if (index >= 0) list.value[index] = updated
if (currentCase.value?.id === id) currentCase.value = updated
if (index >= 0) {
const target = list.value[index]
const delta = target.isFavorite ? -1 : 1
list.value[index] = { ...target, isFavorite: !target.isFavorite, favoriteCount: target.favoriteCount + delta }
}
if (currentCase.value?.id === id) {
const delta = currentCase.value.isFavorite ? -1 : 1
currentCase.value = {
...currentCase.value,
isFavorite: !currentCase.value.isFavorite,
favoriteCount: currentCase.value.favoriteCount + delta,
}
}
}
return {
query,
list,
total,
counts,
previewTotal,
typeOptions,
typeNameById,
industryOptions,
scaleOptions,
caseTotal,
currentCase,
fetchCases,
fetchCounts,
fetchCategories,
ensureCategories,
setQuery,
search,
resetQuery,
changePage,
toggleFavoriteOnly,
fetchCaseDetail,
toggleFavorite,
}

View File

@@ -1,60 +1,266 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { Demand } from '@/types/business/demand'
import type { FileInfo } from '@/types/api'
import type {
BizTypeOption,
Demand,
DemandAttachment,
DemandDraftVo,
DictOption,
} from '@/types/business/demand'
import {
getBizTypeDict,
getBudgetRangeDict,
getDraftDemand,
getOssUrls,
getSubmissionBizTypes,
resetDraft as resetDraftApi,
saveDraft as saveDraftApi,
submitDemand,
uploadDemandFile,
} from '@/api/demand'
import { getUserProfile } from '@/api/user'
import { toAssetUrl } from '@/utils/asset'
import { toBizTypeOption, toBizTypeOptionFromDict } from '@/utils/bizTypeMeta'
import { isManualBudget, resolveDraftBudget } from '@/utils/budgetMeta'
import { formatDate } from '@/utils/format'
import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
function createDefaultForm(): Demand {
return {
businessType: 'internet',
id: null,
bizType: '',
projectName: '',
attachments: [],
coreDescription: '',
expectedDuration: '',
requirementDesc: '',
budgetRange: '',
userName: '王永志',
userPhone: '1866****9618',
remark: '',
status: 'draft',
budgetManual: false,
developmentCycle: '',
supplementaryRemarks: '',
userName: '',
userPhone: '',
attachmentInfo: [],
}
}
/** 需求提报数据(M3:表单 / 步骤 / 提交与草稿) */
/** 字典按 dictSort 升序(后端已排序,前端再保一次序,避免脏序) */
function sortDict(list: DictOption[] | null): DictOption[] {
return [...(list ?? [])].sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0))
}
/**
* 草稿 VO → 表单。空值回退到 base(base 里已放入登录用户资料预填),
* 否则草稿里的空 userName / userPhone 会把预填的联系人信息覆盖掉。
*
* <p>预算范围要单独反推:后端只回传一个 `budgetRange` 字符串、没有「是否手写」的标记
* (`budgetCustom` 恒为 null),故命中字典码值或文案即预设档位,否则视为手填文本。</p>
*/
function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]): Demand {
const budget = resolveDraftBudget(draft.budgetRange, budgetDict)
return {
id: draft.id ?? null,
bizType: draft.bizType || base.bizType,
projectName: draft.projectName || base.projectName,
requirementDesc: draft.requirementDesc || base.requirementDesc,
budgetRange: budget.value,
budgetManual: budget.manual,
developmentCycle: draft.developmentCycle ?? base.developmentCycle,
supplementaryRemarks: draft.supplementaryRemarks || base.supplementaryRemarks,
userName: draft.userName || base.userName,
userPhone: draft.userPhone || base.userPhone,
attachmentInfo: draft.attachmentInfo ?? [],
}
}
/**
* 批量把 OSS 文件标识换成可访问地址。
*
* <p>提报列表的 `logoUrl` 实测是 OSS 主键(如 2099753406859292674)。其中本身已是地址的
* 直接跳过;接口需要登录态,失败时返回空映射,由调用方回退前端内置图标(不阻塞页面)。</p>
*/
async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, string>> {
const ids = [...new Set(
raws.map((item) => (item ?? '').trim()).filter((item) => item && !toAssetUrl(item)),
)]
if (ids.length === 0) return {}
try {
const list = await getOssUrls(ids)
return Object.fromEntries(list.map((item) => [String(item.ossId), item.url]))
}
catch {
return {}
}
}
/** 需求提报数据(单页三段表单 + 真实接口:提报列表 / 字典 / 草稿 / 上传 / 提交) */
export const useDemandStore = defineStore('demand', () => {
const form = ref<Demand>(createDefaultForm())
const step = ref(1)
/** 业务类型卡片选项(**完全由提报列表接口决定**:接口几条就几条,前端不追加任何兜底项) */
const bizTypeOptions = ref<BizTypeOption[]>([])
/** 预算范围字典原始项(值域来源:vm_budget_range) */
const budgetDict = ref<DictOption[]>([])
const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const uploading = ref(false)
function setStep(next: number): void {
step.value = next
/**
* 预算范围选项:全部来自字典,只把「手填」项**固定排到最后**(设计稿要求末尾那一项是可输入的手填位)。
*
* <p>不动字典的值域,只调顺序。渲染方式由 `DemandInfoForm` 决定:非手填项渲染成胶囊按钮,
* 手填项渲染成**同排第 5 个输入框**(占位文案即字典 label)。</p>
*/
const budgetOptions = computed<DictOption[]>(() => {
const list = budgetDict.value
return [
...list.filter((item) => !isManualBudget(item.dictLabel)),
...list.filter((item) => isManualBudget(item.dictLabel)),
]
})
/**
* 业务类型选项:项目提报列表(免登录)→ logo 换链 → 映射卡片选项。
*
* <p>值域**完全由接口决定**:接口返回几条就渲染几张卡,前端不追加任何兜底项
* (包括「其他」——接口有才显示,没有就不显示)。</p>
*
* <p>仅当提报接口**整体不可用**(请求异常)或一条可用记录都没有(全停用/全删除)时,
* 才回退字典 `vm_biz_type`。这仍是接口驱动的降级路径(不是前端凭空造值),
* 目的是避免出现"一个业务类型都选不了、表单无法提交"的死局。</p>
*/
async function loadBizTypeOptions(): Promise<BizTypeOption[]> {
try {
const submissions = await getSubmissionBizTypes()
if (submissions.length > 0) {
const ossUrlById = await loadOssUrlMap(submissions.map((item) => item.logoUrl))
const options = submissions
.map((item) => toBizTypeOption(item, ossUrlById))
.filter((item): item is BizTypeOption => item !== null)
if (options.length > 0) return options
}
}
catch {
// 提报接口不可用时落到字典兜底,避免整页初始化失败
}
const dict = sortDict(await getBizTypeDict())
return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel))
}
function patchForm(partial: Partial<Demand>): void {
form.value = { ...form.value, ...partial }
}
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */
function addAttachment(file: File): void {
const info: FileInfo = {
name: file.name,
size: file.size,
uploadTime: formatDate(new Date()),
url: '',
/** 页面初始化:业务类型 + 预算字典 + 登录用户资料 + 已有草稿(并行) */
async function init(): Promise<void> {
loading.value = true
try {
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
loadBizTypeOptions(),
getBudgetRangeDict(),
// 用户资料失败不阻塞页面(联系人留空时后端会回退取登录用户资料)
getUserProfile().catch(() => null),
getDraftDemand(),
])
bizTypeOptions.value = bizTypes
budgetDict.value = sortDict(budgetDictRes)
const base = createDefaultForm()
if (profile) {
base.userName = profile.nickName || profile.userName || ''
base.userPhone = profile.phoneNumber || ''
}
form.value = draft ? mergeDraft(base, draft, budgetDict.value) : base
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '需求提报数据加载失败')
}
finally {
loading.value = false
}
form.value.attachments = [...form.value.attachments, info]
}
function removeAttachment(target: FileInfo): void {
form.value.attachments = form.value.attachments.filter(
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime),
/** 上传附件:先传 OSS,再把附件记录挂到表单(提交时后端按 ossId 转存) */
async function addAttachment(file: File): Promise<void> {
if (uploading.value) return
uploading.value = true
try {
const oss = await uploadDemandFile(file)
const suffix = (oss.fileSuffix || file.name.split('.').pop() || '').replace(/^\./, '')
const item: DemandAttachment = {
ossId: oss.ossId,
originalName: oss.originalName || file.name,
url: oss.url,
fileSize: file.size,
type: suffix,
createTime: formatDate(new Date()),
}
form.value.attachmentInfo = [...form.value.attachmentInfo, item]
ElMessage.success(`「${item.originalName}」上传成功`)
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '附件上传失败')
}
finally {
uploading.value = false
}
}
/** 移除附件(仅从表单摘除,OSS 文件保留) */
function removeAttachment(target: DemandAttachment): void {
form.value.attachmentInfo = form.value.attachmentInfo.filter(
(item) => !(item.ossId === target.ossId && item.id === target.id),
)
}
/** 提交需求:必填校验通过后调 api,成功返回 true(页面可据此跳转) */
/** 保存草稿(后端按登录用户 upsert) */
async function saveDraft(): Promise<void> {
saving.value = true
try {
await saveDraftApi(form.value)
ElMessage.success('草稿已保存')
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
}
finally {
saving.value = false
}
}
/** 重置:清空后端草稿并复位表单(保留登录账号的联系人预填) */
async function reset(): Promise<void> {
saving.value = true
try {
await resetDraftApi()
const base = createDefaultForm()
base.userName = form.value.userName
base.userPhone = form.value.userPhone
form.value = base
ElMessage.success('已重置')
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重置失败')
}
finally {
saving.value = false
}
}
/**
* 提交需求:前端必填校验 → 提报接口(`/web/requirementPool/create`)→ 复位表单。
*
* <p>接口收的就是草稿那套 BO(`WorkOrderDraftBo`),附件**随本次请求体一起下发**:后端
* `createRequirement` 会逐条把 `attachmentInfo` 的 `ossId` 引用复制成需求附件,所以这里
* 不需要先落一次草稿(这点与后端 `/requirementPool/submit` 不同——那条链路才是从已落库
* 草稿里搬附件)。</p>
*
* <p>后端在同一事务里还会清掉该用户的草稿(`cleanHistoryDrafts`),故提交成功后草稿箱
* 一并清空,用户不会在下次进入页面时看到已经提报过的内容。</p>
*
* <p>⚠️ 该链路只落 `vm_project_requirement` 单表;「我的项目/需求」列表
* (`/web/requirementPool/list`)查的是 `vm_rd_project`,所以本页提交的记录**不会**出现在
* 那个列表里(详见 `api/demand.ts#submitDemand`)。</p>
*/
async function submit(): Promise<boolean> {
if (!form.value.businessType) {
if (!form.value.bizType) {
ElMessage.warning('请选择业务类型')
return false
}
@@ -62,15 +268,23 @@ export const useDemandStore = defineStore('demand', () => {
ElMessage.warning('请填写项目名称')
return false
}
if (!form.value.coreDescription.trim()) {
if (!form.value.requirementDesc.trim()) {
ElMessage.warning('请填写核心需求描述')
return false
}
loading.value = true
// 手写模式下金额会直接作为 budgetRange 提交,故空值必须拦住
if (form.value.budgetManual && !form.value.budgetRange.trim()) {
ElMessage.warning('请填写预算金额,或改选一个预设档位')
return false
}
submitting.value = true
try {
await submitDemand(form.value)
form.value.status = 'submitted'
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系')
const base = createDefaultForm()
base.userName = form.value.userName
base.userPhone = form.value.userPhone
form.value = base
ElMessage.success('提交成功,专业顾问将在一个工作日内与您联系')
return true
}
catch (error) {
@@ -78,49 +292,24 @@ export const useDemandStore = defineStore('demand', () => {
return false
}
finally {
loading.value = false
submitting.value = false
}
}
/** 保存草稿到 sessionStorage */
async function saveDraft(): Promise<void> {
loading.value = true
try {
await saveDraftApi(form.value)
form.value.status = 'draft'
ElMessage.success('草稿已保存')
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
}
finally {
loading.value = false
}
}
/** 恢复本地草稿(无草稿则保持默认表单) */
async function loadDraft(): Promise<void> {
const draft = await getDraftDemand()
if (draft) form.value = { ...createDefaultForm(), ...draft }
}
/** 重置表单与步骤 */
function reset(): void {
form.value = createDefaultForm()
step.value = 1
}
return {
form,
step,
bizTypeOptions,
budgetOptions,
loading,
setStep,
saving,
submitting,
uploading,
init,
patchForm,
addAttachment,
removeAttachment,
submit,
saveDraft,
loadDraft,
reset,
submit,
}
})

View File

@@ -1,46 +1,262 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
import type { BusinessLine, HomeStats } from '@/types/business/home'
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home'
import type {
BannerVo,
BusinessLine,
HomeStats,
ProjectStatusCount,
ProjectSubmissionFlat,
ProjectSubmissionInfo,
ProjectSubmissionVo,
ServiceContentBlock,
ServiceType,
} from '@/types/business/home'
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
import {
getCarouselList,
getPortalEntryList,
getProjectStatusCount,
getServiceTypeDetail,
getServiceTypeList,
switchPortalEntryGroup as switchPortalEntryGroupApi,
} from '@/api/home'
import { getFeaturedCases as getFeaturedCasesApi } from '@/api/cases'
import { toCase } from '@/utils/caseVo'
import { formatBusinessType, toBusinessType } from '@/utils/format'
import { useCasesStore } from '@/stores/modules/cases'
/**
* 首页数据(数据层:取数 + 状态 + 后端 VO → 页面类型映射,页面只调本 store)
*
* 后端接口:
* - UserSideWebHomePageController(前缀 /web/home-page):看板、业务服务线、业务详情
* - ProjectCaseUserController 的 /web/projectCase/mostViewed:案例库精选
*
* 页面/组件消费的类型(BusinessLine / Case / HomeStats)保持不变,
* 后端 VO 与页面字段的差异(字段名、业务类型取值、富文本、数值单位)全部在本层对齐。
*/
// 业务类型归一化(toBusinessType)已抽到 `@/utils/format`,首页与案例库共用。
/** 富文本转纯文本(服务内容区以 <pre> 展示,需去掉 HTML 标签,避免渲染出标签本身) */
function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/** 后端「项目提报」→ 业务线卡片 / 业务详情 */
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
label: item.serviceName ?? '',
desc: '',
icon: item.iconUrl ?? '',
}))
const serviceContent = (vo.backgrounds ?? [])
.map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0)
.join('\n\n')
// 列表接口的「项目背景」富文本按条映射为服务内容区块(保留 HTML 原文,详情页逐块渲染)
const contents: ServiceContentBlock[] = (vo.backgrounds ?? [])
.map((item) => ({ title: '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0)
const subtitle = vo.subtitle ?? ''
return {
id: toBusinessType(vo.businessType),
name: vo.title ?? '',
desc: subtitle,
slogan: subtitle,
icon: vo.logoUrl ?? '',
serviceContent,
contents,
serviceTypes,
video: '',
}
}
/** 后端「业务详情聚合 VO」→ 业务线(主表单对象;服务类型 / 附件 / 富文本目录均为一对多 List,需按数组下钻读取) */
function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat)
const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({
label: item.serviceName ?? '',
desc: '',
icon: item.iconUrl ?? '',
}))
const serviceContent = (info.projectSubmissionCatalog ?? [])
.map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0)
.join('\n\n')
// 一、二、三…每条富文本目录 → 一个「服务内容」区块(保留 HTML,含内嵌图片/视频)
const contents: ServiceContentBlock[] = (info.projectSubmissionCatalog ?? [])
.map((item) => ({ title: item.title ?? '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0)
const subtitle = ps.subtitle ?? ''
return {
id: toBusinessType(ps.businessType),
name: ps.title ?? '',
desc: subtitle,
slogan: subtitle,
icon: ps.logoUrl ?? '',
serviceContent,
contents,
serviceTypes,
video: '',
}
}
// 后端案例 VO → 页面 Case 的映射已抽到 `@/utils/caseVo`(首页与案例库共用)。
/** 未登录时后端看板返回 null,这里兜底为零值 */
const EMPTY_STATUS: ProjectStatusCount = {
projectCount: 0,
ongoingProjectCount: 0,
pendingProjectCount: 0,
}
/** 首页数据(数据层:取数 + 状态,页面只调本 store) */
export const useHomeStore = defineStore('home', () => {
/**
* 案例类型字典(提报 id → 类型名)由案例库 store 提供。
*
* <p>案例表 business_type 列存的是提报主键 id,精选案例标签要翻译成名称就得查这份字典,
* 故首页与案例库共用同一份(见 casesStore.ensureCategories)。</p>
*/
const casesStore = useCasesStore()
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
const banners = ref<BannerVo[]>([])
const businessLines = ref<BusinessLine[]>([])
const featuredCases = ref<Case[]>([])
const currentBusiness = ref<BusinessLine | null>(null)
/** 首页初始化:看板 + 业务线 + 案例精选 */
/** 提报原始列表(内部缓存):业务线卡片与详情共用,详情需按提报主键 id 取数 */
const submissions = ref<ProjectSubmissionVo[]>([])
/** 门户入口原始列表(含快捷 / 其他两组,由 groupType 区分) */
const portalEntries = ref<PortalEntryVo[]>([])
/** 门户入口是否已成功取到(失败时组件回退到内置默认入口) */
const portalEntriesLoaded = ref(false)
/** 快捷入口组(groupType = 1) */
const quickEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType === '1'),
)
/** 其他入口组(groupType ≠ 1) */
const otherEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType !== '1'),
)
/**
* 拉取当前用户的门户入口。
*
* <p>该接口对游客(无登录态)与无权限用户不可用,失败时保持入口为空,
* 由调用方回退到默认入口,不影响首页其余数据。</p>
*/
async function fetchPortalEntries(): Promise<void> {
try {
const list = await getPortalEntryList()
portalEntries.value = list ?? []
portalEntriesLoaded.value = true
} catch {
portalEntries.value = []
portalEntriesLoaded.value = false
}
}
/** 快捷 / 其他入口互转,成功后重新拉取以同步后端排序 */
async function switchEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
await switchPortalEntryGroupApi(id, type)
await fetchPortalEntries()
}
/** 确保提报列表已加载(业务详情页可能被直接打开,此时列表尚未取过) */
async function ensureSubmissions(): Promise<void> {
if (submissions.value.length === 0) {
submissions.value = await getServiceTypeList()
}
}
/** 首页初始化:看板 + 轮播图 + 业务服务线 + 案例精选 */
async function fetchHomeData(): Promise<void> {
const [homeStats, lines, cases] = await Promise.all([
getHomeStats(),
getBusinessLines(),
getFeaturedCases(),
const [statusCount, submissionList, cases, carouselList] = await Promise.all([
getProjectStatusCount(),
getServiceTypeList(),
getFeaturedCasesApi(),
getCarouselList(),
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
casesStore.ensureCategories(),
])
stats.value = homeStats
businessLines.value = lines
featuredCases.value = cases
const status = statusCount ?? EMPTY_STATUS
stats.value = {
pending: status.pendingProjectCount ?? 0,
inProgress: status.ongoingProjectCount ?? 0,
total: status.projectCount ?? 0,
}
submissions.value = submissionList
businessLines.value = submissionList.map(toBusinessLine)
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
banners.value = carouselList ?? []
// 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据
await fetchPortalEntries()
}
/** 按业务类型切换案例精选 */
/**
* 按业务类型切换案例精选。
*
* <p>后端 selectByType 按案例表 business_type 列的**原值**精确匹配,而该列实测存的是
* **提报主键 id**,故这里用 BusinessType 名称经 /categories 字典反查提报 id 再下发;
* 字典里查不到(提报表未登记该类型)时直接置空,不发无效请求。</p>
*/
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
featuredCases.value = await getFeaturedCases(category)
if (category === 'all') {
featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById))
return
}
await casesStore.ensureCategories()
const name = formatBusinessType(category)
const typeId = Object.entries(casesStore.typeNameById).find(([, value]) => value === name)?.[0]
if (!typeId) {
featuredCases.value = []
return
}
featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById))
}
/** 业务详情 */
/** 业务详情:前端 BusinessType 反查提报主键后取详情 */
async function fetchBusinessDetail(type: BusinessType): Promise<void> {
currentBusiness.value = await getBusinessDetail(type)
await ensureSubmissions()
const target = submissions.value.find((item) => toBusinessType(item.businessType) === type)
if (!target || target.id == null) {
currentBusiness.value = null
return
}
currentBusiness.value = toBusinessLineFromInfo(await getServiceTypeDetail(target.id))
}
return {
stats,
banners,
businessLines,
featuredCases,
currentBusiness,
portalEntries,
portalEntriesLoaded,
quickEntries,
otherEntries,
fetchHomeData,
fetchFeaturedCases,
fetchBusinessDetail,
fetchPortalEntries,
switchEntryGroup,
}
})

View File

@@ -1,12 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { PageResult } from '@/types/api'
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project'
import type {
AfterSalesPayload,
Project,
ProjectQuery,
ProjectStatistics,
ProjectTypeCount,
RdProjectVo,
} from '@/types/business/project'
import {
cancelAfterSales as cancelAfterSalesApi,
deleteProjectFile,
getProjectDetail,
getProjectFilterConditions,
getProjectList,
submitAfterSales as submitAfterSalesApi,
uploadProjectFile,
@@ -15,50 +22,125 @@ import {
function createDefaultQuery(): ProjectQuery {
return {
keyword: '',
businessType: 'all',
status: 'all',
businessType: '',
status: '',
timeShortcut: 'all',
page: 1,
startDate: '',
endDate: '',
pageNum: 1,
pageSize: 8,
}
}
/** 项目中心数据(列表 / 筛选 / 分页 / 详情 / 文件 / 售后) */
/**
* 业务类型件数 —— 由 `getFilterConditions` 的统计构建(**全量口径**,不随筛选 / 分页变化)。
*
* <p>「全部」的件数取统计里的 `total`(该用户需求总数);统计拿不到时退回列表 `total`,
* 这种降级下只会渲染出「全部」一行。</p>
*/
function buildTypeCounts(
statistics: ProjectStatistics | null | undefined,
total: number,
): ProjectTypeCount[] {
const counts = statistics?.businessTypeCounts ?? []
return [
{ value: 'all', label: '全部', count: statistics?.total ?? total },
...counts.map((item) => ({
value: String(item.businessTypeId),
label: item.businessTypeLabel,
count: item.count,
})),
]
}
/** 项目中心数据(列表 + 详情走真实接口;文件 / 售后仍是 mock,后端无对应接口) */
export const useProjectStore = defineStore('project', () => {
const query = ref<ProjectQuery>(createDefaultQuery())
const list = ref<Project[]>([])
/** 列表行(后端 RdProjectVo;**不是**详情那套 mock 的 Project) */
const list = ref<RdProjectVo[]>([])
const total = ref(0)
const current = ref<Project | null>(null)
/** 业务类型件数(左侧筛选栏用;由 `ensureFilterConditions` 单独拉取,全量口径) */
const typeCounts = ref<ProjectTypeCount[]>([{ value: 'all', label: '全部', count: 0 }])
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
let filterConditionsLoaded = false
async function fetchList(): Promise<void> {
const result: PageResult<Project> = await getProjectList(query.value)
list.value = result.list
total.value = result.total
/**
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
*
* <p>幂等:成功拉到一次后不再重复请求(值域是全量口径,与当前筛选条件无关)。
* 失败时不标记已加载,下次进页面仍会重试;期间保留上一次结果,首屏即失败则只有「全部」一行。</p>
*/
async function ensureFilterConditions(): Promise<void> {
if (filterConditionsLoaded) return
try {
typeCounts.value = buildTypeCounts(await getProjectFilterConditions(), total.value)
filterConditionsLoaded = true
}
catch (error) {
console.warn('[project] 业务类型筛选条件加载失败', error)
}
}
/**
* 取列表(服务端筛选 + 分页;失败向上抛给调用方处理)。
*
* <p>⚠️ 这里**不**碰 `typeCounts`:`/list` 的 `requirementStatistics` 后端恒为 null,
* 拿它重建会把业务类型整列抹成只剩「全部」,且「全部」件数会变成筛选后的 total(偏小)。
* 业务类型的值域统一由 {@link ensureFilterConditions} 负责。</p>
*/
async function fetchList(): Promise<void> {
const result = await getProjectList(query.value)
const page = result?.requirementPage
list.value = page?.rows ?? []
total.value = page?.total ?? 0
}
/**
* 取详情(真实接口)。
*
* <p>后端在「需求不存在」或「不属于当前用户」时返回 `data: null` ⇒ 页面显示空态;
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
*/
async function fetchDetail(id: string): Promise<void> {
current.value = await getProjectDetail(id)
try {
current.value = await getProjectDetail(id)
}
catch (error) {
current.value = null
console.warn('[project] 项目详情加载失败', error)
}
}
function setQuery(next: ProjectQuery): void {
query.value = next
}
/** 确定筛选 / 换页后的取数统一入口(失败只提示,不清空已有数据) */
async function load(): Promise<void> {
try {
await fetchList()
}
catch (error) {
console.warn('[project] 项目列表加载失败', error)
}
}
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> {
query.value = { ...query.value, page: 1 }
await fetchList()
query.value = { ...query.value, pageNum: 1 }
await load()
}
/** 重置全部条件 */
async function resetQuery(): Promise<void> {
query.value = createDefaultQuery()
await fetchList()
await load()
}
async function changePage(page: number): Promise<void> {
query.value = { ...query.value, page }
await fetchList()
async function changePage(pageNum: number): Promise<void> {
query.value = { ...query.value, pageNum }
await load()
}
/** 上传项目文件(追加到当前详情) */
@@ -92,9 +174,12 @@ export const useProjectStore = defineStore('project', () => {
list,
total,
current,
typeCounts,
ensureFilterConditions,
fetchList,
fetchDetail,
setQuery,
load,
search,
resetQuery,
changePage,

View File

@@ -4,18 +4,18 @@ import { ElMessage } from 'element-plus'
import type {
LoginByAccountParams,
LoginBySmsParams,
LoginResult,
RegisterParams,
ResetPasswordParams,
User,
} from '@/types/business/user'
import {
loginByAccount as loginByAccountApi,
loginBySms as loginBySmsApi,
register as registerApi,
resetPassword as resetPasswordApi,
sendSmsCode as sendSmsCodeApi,
} from '@/api/auth'
import {
loginByAccountApi,
registerByAccountApi,
} from '@/api/auth'
import { storage } from '@/utils/storage'
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
@@ -26,7 +26,7 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => Boolean(token.value))
/** 发送短信验证码 */
/** 发送短信验证码(保留原 mock 逻辑) */
async function sendSmsCode(phone: string): Promise<void> {
try {
await sendSmsCodeApi(phone)
@@ -39,18 +39,18 @@ export const useUserStore = defineStore('user', () => {
}
/** 登录成功统一落库(token 持久化) */
function applyLogin(result: LoginResult): void {
token.value = result.token
user.value = result.user
storage.set('token', result.token)
function applyLogin(payload: { token: string; user: User | null }): void {
token.value = payload.token
user.value = payload.user
storage.set('token', payload.token)
}
/** 账号密码登录 */
/** 账号密码登录(对接真实后端 /auth/login,返回 access_token) */
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
loading.value = true
try {
const result = await loginByAccountApi(params)
applyLogin(result)
const result = await loginByAccountApi({ username: params.account, password: params.password })
applyLogin({ token: result.access_token, user: null })
ElMessage.success('登录成功')
return true
}
@@ -63,12 +63,12 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 手机验证码登录 */
/** 手机验证码登录(保留原 mock 逻辑) */
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
loading.value = true
try {
const result = await loginBySmsApi(params)
applyLogin(result)
applyLogin({ token: result.token, user: result.user })
ElMessage.success('登录成功')
return true
}
@@ -81,11 +81,11 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 注册 */
async function register(params: RegisterParams): Promise<boolean> {
/** 注册(对接真实后端 /auth/register,账号+密码,无验证码) */
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
loading.value = true
try {
await registerApi(params)
await registerByAccountApi({ username: params.username, password: params.password })
ElMessage.success('注册成功,请登录')
return true
}
@@ -98,7 +98,7 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 重置密码 */
/** 重置密码(保留原 mock 逻辑) */
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
loading.value = true
try {