恢复修改的数据
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user