feat 完善注释
This commit is contained in:
@@ -13,33 +13,8 @@ import { collectCase, getCaseCategories, getCaseDetail, getCaseList } from '@/ap
|
||||
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 {
|
||||
@@ -55,7 +30,6 @@ function createDefaultQuery(): CaseQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 筛选面板给的是 yyyy-MM-dd,后端 Date 入参按 yyyy-MM-dd HH:mm:ss 补全 */
|
||||
function withTime(date: string, time: string): string {
|
||||
return date.length === 10 ? `${date} ${time}` : date
|
||||
}
|
||||
@@ -64,12 +38,6 @@ 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) => ({
|
||||
@@ -80,7 +48,6 @@ function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[]
|
||||
.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 ?? []) {
|
||||
@@ -95,25 +62,18 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
const query = ref<CaseQuery>(createDefaultQuery())
|
||||
const list = ref<Case[]>([])
|
||||
const total = ref(0)
|
||||
/** 待提交条件的结果数(筛选面板改动后即时探测,仅用于「确定筛选(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
|
||||
@@ -121,14 +81,12 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
} {
|
||||
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) {
|
||||
@@ -141,12 +99,6 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
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
|
||||
@@ -157,17 +109,10 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
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 { bo, pageNum, pageSize } = buildQuery(query.value)
|
||||
const result = await getCaseList(bo, pageNum, pageSize)
|
||||
@@ -176,7 +121,6 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
previewTotal.value = total.value
|
||||
}
|
||||
|
||||
/** 待提交条件的结果数(防抖,避免每次点击都打接口) */
|
||||
async function probeTotal(): Promise<void> {
|
||||
const { bo } = buildQuery(query.value)
|
||||
const result = await getCaseList(bo, 1, 1)
|
||||
@@ -188,19 +132,16 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY)
|
||||
}
|
||||
|
||||
/** 面板改条件(只改待提交条件,不取数;取数由「确定筛选」触发) */
|
||||
function setQuery(next: CaseQuery): void {
|
||||
query.value = next
|
||||
scheduleProbe()
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchCases()
|
||||
@@ -211,24 +152,16 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
/** 「收藏列表」开关:只筛已收藏案例 */
|
||||
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> {
|
||||
await collectCase(id)
|
||||
const index = list.value.findIndex((item) => item.id === id)
|
||||
|
||||
@@ -17,7 +17,6 @@ function createDefaultQuery(): ContractQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
|
||||
export const useContractStore = defineStore('contract', () => {
|
||||
const query = ref<ContractQuery>(createDefaultQuery())
|
||||
const list = ref<Contract[]>([])
|
||||
@@ -39,13 +38,11 @@ export const useContractStore = defineStore('contract', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -56,7 +53,6 @@ export const useContractStore = defineStore('contract', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
|
||||
async function fetchSignQr(id: string, refresh = false): Promise<void> {
|
||||
const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
|
||||
signExpires.value = result.expiresInSeconds
|
||||
|
||||
@@ -41,18 +41,10 @@ function createDefaultForm(): Demand {
|
||||
}
|
||||
}
|
||||
|
||||
/** 字典按 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 {
|
||||
@@ -70,12 +62,6 @@ function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把 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)),
|
||||
@@ -90,24 +76,15 @@ async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, st
|
||||
}
|
||||
}
|
||||
|
||||
/** 需求提报数据(单页三段表单 + 真实接口:提报列表 / 字典 / 草稿 / 上传 / 提交) */
|
||||
export const useDemandStore = defineStore('demand', () => {
|
||||
const form = ref<Demand>(createDefaultForm())
|
||||
/** 业务类型卡片选项(**完全由提报列表接口决定**:接口几条就几条,前端不追加任何兜底项) */
|
||||
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)
|
||||
|
||||
/**
|
||||
* 预算范围选项:全部来自字典,只把「手填」项**固定排到最后**(设计稿要求末尾那一项是可输入的手填位)。
|
||||
*
|
||||
* <p>不动字典的值域,只调顺序。渲染方式由 `DemandInfoForm` 决定:非手填项渲染成胶囊按钮,
|
||||
* 手填项渲染成**同排第 5 个输入框**(占位文案即字典 label)。</p>
|
||||
*/
|
||||
const budgetOptions = computed<DictOption[]>(() => {
|
||||
const list = budgetDict.value
|
||||
return [
|
||||
@@ -116,16 +93,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 业务类型选项:项目提报列表(免登录)→ logo 换链 → 映射卡片选项。
|
||||
*
|
||||
* <p>值域**完全由接口决定**:接口返回几条就渲染几张卡,前端不追加任何兜底项
|
||||
* (包括「其他」——接口有才显示,没有就不显示)。</p>
|
||||
*
|
||||
* <p>仅当提报接口**整体不可用**(请求异常)或一条可用记录都没有(全停用/全删除)时,
|
||||
* 才回退字典 `vm_biz_type`。这仍是接口驱动的降级路径(不是前端凭空造值),
|
||||
* 目的是避免出现"一个业务类型都选不了、表单无法提交"的死局。</p>
|
||||
*/
|
||||
async function loadBizTypeOptions(): Promise<BizTypeOption[]> {
|
||||
try {
|
||||
const submissions = await getSubmissionBizTypes()
|
||||
@@ -138,7 +105,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 提报接口不可用时落到字典兜底,避免整页初始化失败
|
||||
}
|
||||
const dict = sortDict(await getBizTypeDict())
|
||||
return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel))
|
||||
@@ -148,14 +114,12 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
form.value = { ...form.value, ...partial }
|
||||
}
|
||||
|
||||
/** 页面初始化:业务类型 + 预算字典 + 登录用户资料 + 已有草稿(并行) */
|
||||
async function init(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
|
||||
loadBizTypeOptions(),
|
||||
getBudgetRangeDict(),
|
||||
// 用户资料失败不阻塞页面(联系人留空时后端会回退取登录用户资料)
|
||||
getUserProfile().catch(() => null),
|
||||
getDraftDemand(),
|
||||
])
|
||||
@@ -177,7 +141,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传附件:先传 OSS,再把附件记录挂到表单(提交时后端按 ossId 转存) */
|
||||
async function addAttachment(file: File): Promise<void> {
|
||||
if (uploading.value) return
|
||||
uploading.value = true
|
||||
@@ -203,14 +166,12 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除附件(仅从表单摘除,OSS 文件保留) */
|
||||
function removeAttachment(target: DemandAttachment): void {
|
||||
form.value.attachmentInfo = form.value.attachmentInfo.filter(
|
||||
(item) => !(item.ossId === target.ossId && item.id === target.id),
|
||||
)
|
||||
}
|
||||
|
||||
/** 保存草稿(后端按登录用户 upsert) */
|
||||
async function saveDraft(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -225,7 +186,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置:清空后端草稿并复位表单(保留登录账号的联系人预填) */
|
||||
async function reset(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -244,21 +204,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交需求:前端必填校验 → 提报接口(`/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.bizType) {
|
||||
ElMessage.warning('请选择业务类型')
|
||||
@@ -272,7 +217,6 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
ElMessage.warning('请填写核心需求描述')
|
||||
return false
|
||||
}
|
||||
// 手写模式下金额会直接作为 budgetRange 提交,故空值必须拦住
|
||||
if (form.value.budgetManual && !form.value.budgetRange.trim()) {
|
||||
ElMessage.warning('请填写预算金额,或改选一个预设档位')
|
||||
return false
|
||||
|
||||
@@ -43,42 +43,31 @@ function createDefaultInvoiceQuery(): InvoiceQuery {
|
||||
return { keyword: '', status: 'all', page: 1, pageSize: 6 }
|
||||
}
|
||||
|
||||
/** 财务中心数据(统计/账单/支付/发票/抬头) */
|
||||
export const useFinanceStore = defineStore('finance', () => {
|
||||
// —— 统计
|
||||
const stats = ref<FinanceStats | null>(null)
|
||||
|
||||
// —— 账单列表
|
||||
const list = ref<Bill[]>([])
|
||||
const total = ref(0)
|
||||
const query = ref<BillQuery>(createDefaultBillQuery())
|
||||
const currentBill = ref<Bill | null>(null)
|
||||
|
||||
// —— 支付
|
||||
const payment = ref<Payment | null>(null)
|
||||
const result = ref<PaymentResult | null>(null)
|
||||
|
||||
// —— 收银台(确认支付页)
|
||||
/** 收银台待支付账单(由路由 `?ids=` 拉取) */
|
||||
const checkoutBills = ref<Bill[]>([])
|
||||
const checkoutLoading = ref(false)
|
||||
/** 最近一次下单响应(微信 codeUrl / 支付宝跳转地址) */
|
||||
const payOrder = ref<PayOrderResponse | null>(null)
|
||||
|
||||
// —— 发票
|
||||
const invoices = ref<Invoice[]>([])
|
||||
const invoiceTotal = ref(0)
|
||||
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
|
||||
|
||||
// —— 发票抬头
|
||||
const titles = ref<InvoiceTitle[]>([])
|
||||
|
||||
// —— 统计
|
||||
async function fetchStats(period?: string): Promise<void> {
|
||||
stats.value = await getFinanceStats(period)
|
||||
}
|
||||
|
||||
// —— 账单
|
||||
async function fetchList(): Promise<void> {
|
||||
const pageData: PageResult<Bill> = await getBillList(query.value)
|
||||
list.value = pageData.list
|
||||
@@ -89,13 +78,11 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultBillQuery()
|
||||
await fetchList()
|
||||
@@ -110,12 +97,10 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
currentBill.value = await getBillDetail(id)
|
||||
}
|
||||
|
||||
// —— 支付
|
||||
async function createPayment(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await createPaymentApi(billId, method)
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await switchPaymentMethodApi(billId, method)
|
||||
}
|
||||
@@ -126,15 +111,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
: await getPayResult(billId)
|
||||
}
|
||||
|
||||
// —— 收银台(确认支付)
|
||||
/**
|
||||
* 拉取收银台订单明细。
|
||||
*
|
||||
* <p>入参是**需求 id**(项目详情路由 `:id`):数据走项目详情那个现成接口
|
||||
* `GET /web/requirementPool/get/finance/{需求id}` 的 `billPlans`,不另开端点。
|
||||
* `ids` 为可选的期次 id(`vm_bill_plan.id`),用于精确指定付哪一期;
|
||||
* 不传则取该需求下状态为「待支付」的期。</p>
|
||||
*/
|
||||
async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> {
|
||||
checkoutLoading.value = true
|
||||
try {
|
||||
@@ -144,27 +120,18 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 Native 下单(拿到 codeUrl 后由页面渲染二维码)。
|
||||
*
|
||||
* @param billIds 本笔支付覆盖的账单 id。后端据此落 `vm_finance_payment` 并把它写进
|
||||
* `request_payload`,支付成功回调再为每张账单落 `vm_finance_payment_bill`。
|
||||
*/
|
||||
async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> {
|
||||
payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds)
|
||||
}
|
||||
|
||||
/** 支付宝电脑网站支付下单(拿到跳转地址后由页面跳转) */
|
||||
async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> {
|
||||
payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds)
|
||||
}
|
||||
|
||||
/** 清空下单响应(关闭二维码弹窗时调用) */
|
||||
function clearPayOrder(): void {
|
||||
payOrder.value = null
|
||||
}
|
||||
|
||||
// —— 发票
|
||||
async function fetchInvoices(): Promise<void> {
|
||||
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
|
||||
invoices.value = pageData.list
|
||||
@@ -175,7 +142,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
invoiceQuery.value = next
|
||||
}
|
||||
|
||||
/** 发票确定筛选(重置页码后取数) */
|
||||
async function searchInvoices(): Promise<void> {
|
||||
invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
|
||||
await fetchInvoices()
|
||||
@@ -186,14 +152,12 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
await fetchInvoices()
|
||||
}
|
||||
|
||||
/** 申请开票(状态 → 开票中) */
|
||||
async function applyInvoice(id: string): Promise<void> {
|
||||
const updated = await applyInvoiceApi(id)
|
||||
const index = invoices.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) invoices.value[index] = updated
|
||||
}
|
||||
|
||||
// —— 发票抬头
|
||||
async function fetchTitles(): Promise<void> {
|
||||
titles.value = await getInvoiceTitles()
|
||||
}
|
||||
@@ -217,7 +181,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
titles.value = await setDefaultTitleApi(id)
|
||||
}
|
||||
|
||||
// —— 报表导出
|
||||
async function exportReport(): Promise<void> {
|
||||
await exportReportApi()
|
||||
}
|
||||
|
||||
@@ -28,20 +28,6 @@ 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')
|
||||
@@ -55,7 +41,6 @@ function stripHtml(html: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** 后端轮播 VO → 页面 Banner(仅启用项,按 sort 升序) */
|
||||
function toBanners(list: BannerVo[]): Banner[] {
|
||||
return list
|
||||
.filter((item) => item.enabled !== false && Boolean(item.imageUrl))
|
||||
@@ -66,7 +51,6 @@ function toBanners(list: BannerVo[]): Banner[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/** 后端「项目提报」→ 业务线卡片 / 业务详情 */
|
||||
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
||||
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
|
||||
label: item.serviceName ?? '',
|
||||
@@ -77,7 +61,6 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
||||
.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)
|
||||
@@ -95,7 +78,6 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端「业务详情聚合 VO」→ 业务线(主表单对象;服务类型 / 附件 / 富文本目录均为一对多 List,需按数组下钻读取) */
|
||||
function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
|
||||
const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat)
|
||||
const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({
|
||||
@@ -107,7 +89,6 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
|
||||
.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)
|
||||
@@ -125,9 +106,6 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
|
||||
}
|
||||
}
|
||||
|
||||
// 后端案例 VO → 页面 Case 的映射已抽到 `@/utils/caseVo`(首页与案例库共用)。
|
||||
|
||||
/** 未登录时后端看板返回 null,这里兜底为零值 */
|
||||
const EMPTY_STATUS: ProjectStatusCount = {
|
||||
projectCount: 0,
|
||||
ongoingProjectCount: 0,
|
||||
@@ -135,12 +113,6 @@ const EMPTY_STATUS: ProjectStatusCount = {
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -149,31 +121,20 @@ export const useHomeStore = defineStore('home', () => {
|
||||
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()
|
||||
@@ -185,27 +146,23 @@ export const useHomeStore = defineStore('home', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 快捷 / 其他入口互转,成功后重新拉取以同步后端排序 */
|
||||
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 [statusCount, submissionList, cases, carouselList] = await Promise.all([
|
||||
getProjectStatusCount(),
|
||||
getServiceTypeList(),
|
||||
getFeaturedCasesApi(),
|
||||
getCarouselList().catch(() => [] as BannerVo[]),
|
||||
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
|
||||
casesStore.ensureCategories(),
|
||||
])
|
||||
const status = statusCount ?? EMPTY_STATUS
|
||||
@@ -218,17 +175,9 @@ export const useHomeStore = defineStore('home', () => {
|
||||
businessLines.value = submissionList.map(toBusinessLine)
|
||||
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
banners.value = toBanners(carouselList ?? [])
|
||||
// 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据
|
||||
await fetchPortalEntries()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务类型切换案例精选。
|
||||
*
|
||||
* <p>后端 selectByType 按案例表 business_type 列的**原值**精确匹配,而该列实测存的是
|
||||
* **提报主键 id**,故这里用 BusinessType 名称经 /categories 字典反查提报 id 再下发;
|
||||
* 字典里查不到(提报表未登记该类型)时直接置空,不发无效请求。</p>
|
||||
*/
|
||||
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
|
||||
if (category === 'all') {
|
||||
featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
@@ -244,7 +193,6 @@ export const useHomeStore = defineStore('home', () => {
|
||||
featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById))
|
||||
}
|
||||
|
||||
/** 业务详情:前端 BusinessType 反查提报主键后取详情 */
|
||||
async function fetchBusinessDetail(type: BusinessType): Promise<void> {
|
||||
await ensureSubmissions()
|
||||
const target = submissions.value.find((item) => toBusinessType(item.businessType) === type)
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
markRead as markReadApi,
|
||||
} from '@/api/message'
|
||||
|
||||
/** tab 计数结构 */
|
||||
export interface MessageTabCounts {
|
||||
all: number
|
||||
在线客服: number
|
||||
@@ -29,7 +28,6 @@ function createDefaultQuery(): MessageQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const query = ref<MessageQuery>(createDefaultQuery())
|
||||
const list = ref<Message[]>([])
|
||||
@@ -59,13 +57,11 @@ export const useMessageStore = defineStore('message', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定搜索(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -76,7 +72,6 @@ export const useMessageStore = defineStore('message', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
async function markAllRead(): Promise<void> {
|
||||
await markAllReadApi()
|
||||
ElMessage.success('已全部标记为已读')
|
||||
|
||||
@@ -4,13 +4,6 @@ import { fetchOssPreviewBlob } from '@/api/oss'
|
||||
import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
|
||||
/**
|
||||
* OSS 文件预览全局状态。
|
||||
*
|
||||
* <p>所有 `FileList` 的「预览」统一走这里,弹一个全局 `FilePreviewDialog`:
|
||||
* 有 `ossId` 的文件经同源后端接口取 blob 内联渲染(绕开桶强制下载);
|
||||
* 无 `ossId` 的图片可退化为公网直链(子资源渲染不受强制下载影响),其余提示「不支持在线预览」。</p>
|
||||
*/
|
||||
export const useOssStore = defineStore('oss', () => {
|
||||
const visible = ref(false)
|
||||
const file = ref<FileInfo | null>(null)
|
||||
@@ -19,9 +12,7 @@ export const useOssStore = defineStore('oss', () => {
|
||||
const textContent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
/** 文本类文件超过预览上限被截断渲染 */
|
||||
const truncated = ref(false)
|
||||
/** 被截断文件的实际大小(字节) */
|
||||
const totalBytes = ref(0)
|
||||
|
||||
function revokeObjectUrl(): void {
|
||||
@@ -44,13 +35,11 @@ export const useOssStore = defineStore('oss', () => {
|
||||
const previewKind = resolvePreviewKind(target.name)
|
||||
kind.value = previewKind
|
||||
|
||||
// 不支持内联渲染(office / 未知类型):直接提示,等用户下载
|
||||
if (previewKind === 'unsupported') {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 无 ossId:图片可走公网直链(<img> 子资源渲染不受强制下载影响),其余无法预览
|
||||
if (target.ossId === undefined || target.ossId === null) {
|
||||
if (previewKind === 'image' && target.url) {
|
||||
objectUrl.value = target.url
|
||||
@@ -65,8 +54,6 @@ export const useOssStore = defineStore('oss', () => {
|
||||
try {
|
||||
const blob = await fetchOssPreviewBlob(target.ossId)
|
||||
if (previewKind === 'text') {
|
||||
// 整文件塞进 <pre> 会卡死浏览器(如 5MB+ 的 .sql/.log),
|
||||
// 仅读取前 PREVIEW_TEXT_MAX_BYTES 渲染,超出截断并提示下载
|
||||
const total = blob.size
|
||||
if (total > PREVIEW_TEXT_MAX_BYTES) {
|
||||
textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text()
|
||||
@@ -76,7 +63,6 @@ export const useOssStore = defineStore('oss', () => {
|
||||
textContent.value = await blob.text()
|
||||
}
|
||||
} else {
|
||||
// 图片 / PDF / 音视频由浏览器原生处理,5MB 量级毫无压力
|
||||
objectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
@@ -51,9 +51,7 @@ function createDefaultFavoritesQuery(): FavoritesQuery {
|
||||
return { page: 1, pageSize: 8, keyword: '', category: 'all' }
|
||||
}
|
||||
|
||||
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
|
||||
export const useProfileStore = defineStore('profile', () => {
|
||||
// ── 账号资料 ──
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
async function fetchProfile(): Promise<void> {
|
||||
@@ -65,7 +63,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('昵称修改成功')
|
||||
}
|
||||
|
||||
// ── 账号安全 ──
|
||||
async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
|
||||
await changePassword(params)
|
||||
profile.value = await getProfile()
|
||||
@@ -97,7 +94,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('注销成功')
|
||||
}
|
||||
|
||||
// ── 收货地址 ──
|
||||
const addresses = ref<Address[]>([])
|
||||
const addressTotal = ref(0)
|
||||
const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
|
||||
@@ -138,7 +134,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
// ── 我的收藏 ──
|
||||
const favorites = ref<Case[]>([])
|
||||
const favoritesTotal = ref(0)
|
||||
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
@@ -169,7 +164,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
// ── 资质管理 ──
|
||||
const qualifications = ref<Qualification[]>([])
|
||||
const qualificationTotal = ref(0)
|
||||
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
|
||||
@@ -204,7 +198,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
// ── 意见反馈 / 帮助中心 ──
|
||||
async function submitFeedbackAction(feedback: Feedback): Promise<void> {
|
||||
await submitFeedbackApi(feedback)
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ function createDefaultQuery(): ProjectQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务类型件数 —— 由 `getFilterConditions` 的统计构建(**全量口径**,不随筛选 / 分页变化)。
|
||||
*
|
||||
* <p>「全部」的件数取统计里的 `total`(该用户需求总数);统计拿不到时退回列表 `total`,
|
||||
* 这种降级下只会渲染出「全部」一行。</p>
|
||||
*/
|
||||
function buildTypeCounts(
|
||||
statistics: ProjectStatistics | null | undefined,
|
||||
total: number,
|
||||
@@ -58,49 +52,26 @@ function buildTypeCounts(
|
||||
]
|
||||
}
|
||||
|
||||
/** 项目中心数据(列表 + 详情走真实接口;文件 / 售后仍是 mock,后端无对应接口) */
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const query = ref<ProjectQuery>(createDefaultQuery())
|
||||
/** 列表行(后端 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
|
||||
|
||||
/**
|
||||
* 已完成项目(**申请售后页「项目名称」下拉的候选**)。
|
||||
*
|
||||
* <p>口径见 `api/project.ts#getCompletedProjects`:自己名下已立项且状态为 COMPLETED 的项目,
|
||||
* 与后端「只有已完成的项目可以申请售后」的准入校验一致。行的 `id` 就是申请接口要传的需求 id。</p>
|
||||
*
|
||||
* <p>与业务类型的筛选条件不同,这个值域**会随项目状态变化**,所以不缓存、
|
||||
* 每次进申请页都重拉(见 {@link fetchCompletedProjects})。</p>
|
||||
*/
|
||||
const completedProjects = ref<RdProjectVo[]>([])
|
||||
|
||||
/** 详情原始 bundle(4 支接口的响应);切页签时按页签替换其中一支后重新映射 */
|
||||
const detailBundle = ref<RequirementDetailBundle | null>(null)
|
||||
/** 详情请求序号:需求 id 一变就自增,用来丢弃在途的「上一个需求」的响应 */
|
||||
let detailSeq = 0
|
||||
/** 各页签自己的请求序号:同一页签连点只应用最后一次响应 */
|
||||
const tabSeq: Record<ProjectDetailTab, number> = {
|
||||
progress: 0,
|
||||
files: 0,
|
||||
finance: 0,
|
||||
afterSales: 0,
|
||||
}
|
||||
/** 正在刷新的页签('' = 无);页签按钮据此显示加载态 */
|
||||
const loadingTab = ref<ProjectDetailTab | ''>('')
|
||||
|
||||
/**
|
||||
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
|
||||
*
|
||||
* <p>幂等:成功拉到一次后不再重复请求(值域是全量口径,与当前筛选条件无关)。
|
||||
* 失败时不标记已加载,下次进页面仍会重试;期间保留上一次结果,首屏即失败则只有「全部」一行。</p>
|
||||
*/
|
||||
async function ensureFilterConditions(): Promise<void> {
|
||||
if (filterConditionsLoaded) return
|
||||
try {
|
||||
@@ -112,13 +83,6 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取列表(服务端筛选 + 分页;失败向上抛给调用方处理)。
|
||||
*
|
||||
* <p>⚠️ 这里**不**碰 `typeCounts`:`/list` 的 `requirementStatistics` 后端恒为 null,
|
||||
* 拿它重建会把业务类型整列抹成只剩「全部」,且「全部」件数会变成筛选后的 total(偏小)。
|
||||
* 业务类型的值域统一由 {@link ensureFilterConditions} 负责。</p>
|
||||
*/
|
||||
async function fetchList(): Promise<void> {
|
||||
const result = await getProjectList(query.value)
|
||||
const page = result?.requirementPage
|
||||
@@ -126,12 +90,6 @@ export const useProjectStore = defineStore('project', () => {
|
||||
total.value = page?.total ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「已完成项目」(申请售后页「项目名称」下拉的值域)。
|
||||
*
|
||||
* <p>失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组,
|
||||
* 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。</p>
|
||||
*/
|
||||
async function fetchCompletedProjects(): Promise<void> {
|
||||
try {
|
||||
completedProjects.value = await getCompletedProjects()
|
||||
@@ -141,21 +99,11 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取详情(**首屏**:4 支并行,一次到位)。
|
||||
*
|
||||
* <p>后端在「需求不存在」或「不属于当前用户」时 4 支都返回 `data: null` ⇒ 页面显示空态;
|
||||
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
|
||||
*
|
||||
* <p>原始响应同时缓存进 `detailBundle`,之后切页签只替换其中一支(见 {@link refreshTab}),
|
||||
* 不必重拉另外三支。</p>
|
||||
*/
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
const seq = ++detailSeq
|
||||
loadingTab.value = ''
|
||||
try {
|
||||
const bundle = await getProjectDetailBundle(id)
|
||||
// 期间已切到别的需求 ⇒ 丢弃这次响应,别用旧项目覆盖新项目
|
||||
if (seq !== detailSeq) return
|
||||
if (!bundle) {
|
||||
detailBundle.value = null
|
||||
@@ -173,17 +121,6 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切页签刷新:**只调该页签对应的那一个接口**,拿到最新数据后落回 bundle 再重新映射。
|
||||
*
|
||||
* <p>后端 4 个页签各一个端点 ⇒ 「点一次页签 = 一次请求」,不需要重拉另外三支。
|
||||
* 页签内容(里程碑 / 付款计划 / 附件 / 售后…)可能被别的入口改动,所以每次点击都重新取。</p>
|
||||
*
|
||||
* <p>并发保护两层:① 需求 id 变了(切项目)⇒ 丢弃在途响应;② 同一页签连点 ⇒ 只应用
|
||||
* 最后一次响应(先到的慢响应被后到的覆盖)。</p>
|
||||
*
|
||||
* <p>后端返回 `null`(需求不存在 / 非本人)时**保留现有数据**,只告警、不清空页面。</p>
|
||||
*/
|
||||
async function refreshTab(tab: ProjectDetailTab): Promise<void> {
|
||||
const requestId = current.value?.id
|
||||
if (!requestId) return
|
||||
@@ -223,7 +160,6 @@ export const useProjectStore = defineStore('project', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选 / 换页后的取数统一入口(失败只提示,不清空已有数据) */
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
await fetchList()
|
||||
@@ -233,13 +169,11 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, pageNum: 1 }
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await load()
|
||||
@@ -250,31 +184,16 @@ export const useProjectStore = defineStore('project', () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传售后附件(提交前的暂存步骤)。
|
||||
*
|
||||
* <p>只传 OSS、不落业务表;返回项带 `ossId`,由申请页收集后在提交时下发
|
||||
* `attachmentOssIds`,后端再转存成售后附件。</p>
|
||||
*/
|
||||
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||
return uploadAfterSalesFileApi(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请售后(成功后就地重拉「售后信息」页签)。
|
||||
*
|
||||
* <p>不拿返回值本地拼状态:售后状态与工单附件都由后端决定,本地拼容易和真实数据漂移。
|
||||
* 接口失败(如「只有已完成的项目可以申请售后」)会抛出,由统一 request 层弹提示,
|
||||
* 这里不做兜底吞异常。</p>
|
||||
*/
|
||||
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
|
||||
await submitAfterSalesApi(id, payload)
|
||||
ElMessage.success('售后申请已提交')
|
||||
// 详情页在场时同步刷新该页签;从申请页提交时会立刻跳回详情,那边 onMounted 会重新取数
|
||||
if (current.value?.id === id) await refreshTab('afterSales')
|
||||
}
|
||||
|
||||
/** 撤回售后申请(成功后就地重拉「售后信息」页签) */
|
||||
async function cancelAfterSales(id: string): Promise<void> {
|
||||
await cancelAfterSalesApi(id)
|
||||
ElMessage.success('售后申请已撤回')
|
||||
@@ -289,24 +208,18 @@ export const useProjectStore = defineStore('project', () => {
|
||||
typeCounts,
|
||||
ensureFilterConditions,
|
||||
fetchList,
|
||||
/** 已完成项目(申请售后页「项目名称」下拉候选) */
|
||||
completedProjects,
|
||||
fetchCompletedProjects,
|
||||
fetchDetail,
|
||||
/** 切页签刷新(每次点击都调用,只拉该页签那一支) */
|
||||
refreshTab,
|
||||
/** 正在刷新的页签,供按钮显示加载态 */
|
||||
loadingTab,
|
||||
setQuery,
|
||||
load,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
/** 上传售后附件(申请页用) */
|
||||
uploadAfterSalesFile,
|
||||
/** 申请售后(成功后自动刷新售后页签) */
|
||||
submitAfterSales,
|
||||
/** 撤回售后申请(成功后自动刷新售后页签) */
|
||||
cancelAfterSales,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
uploadChatFile,
|
||||
} from '@/api/service'
|
||||
|
||||
/** 在线客服会话数据(消息列表 / 发送状态) */
|
||||
export const useServiceStore = defineStore('service', () => {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const sending = ref(false)
|
||||
@@ -17,7 +16,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
messages.value = await getChatHistory()
|
||||
}
|
||||
|
||||
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
|
||||
async function send(content: string): Promise<void> {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || sending.value) return
|
||||
@@ -37,7 +35,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送文件卡片消息 */
|
||||
async function sendFile(file: File): Promise<void> {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 站点公共数据(站点配置、全局 loading),非页面级 */
|
||||
export const useSiteStore = defineStore('site', () => {
|
||||
const globalLoading = ref(false)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '@/api/auth'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(storage.get<string>('token'))
|
||||
const user = ref<User | null>(null)
|
||||
@@ -26,7 +25,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(token.value))
|
||||
|
||||
/** 发送短信验证码(保留原 mock 逻辑) */
|
||||
async function sendSmsCode(phone: string): Promise<void> {
|
||||
try {
|
||||
await sendSmsCodeApi(phone)
|
||||
@@ -38,14 +36,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录成功统一落库(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 {
|
||||
@@ -63,7 +59,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机验证码登录(保留原 mock 逻辑) */
|
||||
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -81,7 +76,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册(对接真实后端 /auth/register,账号+密码,无验证码) */
|
||||
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -98,7 +92,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置密码(保留原 mock 逻辑) */
|
||||
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -115,7 +108,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录(清除持久化 token) */
|
||||
function logout(): void {
|
||||
token.value = null
|
||||
user.value = null
|
||||
|
||||
Reference in New Issue
Block a user