恢复修改的数据

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,14 +1,55 @@
import { request, CLIENT_ID } from '@/utils/request'
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
import type {
LoginByAccountParams,
LoginBySmsParams,
LoginResult,
RegisterParams,
ResetPasswordParams,
User,
} from '@/types/business/user'
import { isPhone, isValidPassword } from '@/utils/validate'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
/**
* PC 端 clientId(sys_client 种子数据,grantType 含 password)。
* 与请求头 clientid 同源(见 @/utils/request),改一处即可整体生效。
*/
export const PC_CLIENT_ID = CLIENT_ID
/**
* 账号密码登录:POST /auth/login
* 后端要求请求体含 clientId + grantType + username + password(api-decrypt 未开启,明文即可)
*/
export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> {
return request<AuthLoginResult>({
url: '/auth/login',
method: 'post',
data: {
username: data.username,
password: data.password,
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
/**
* 账号密码注册:POST /auth/register
* userType=sys_user;需后端开启注册开关(sys.account.registerUser=true),否则返回「当前系统没有开启注册功能!」
*/
export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> {
return request<RegisterResult>({
url: '/auth/register',
method: 'post',
data: {
username: data.username,
password: data.password,
userType: 'sys_user',
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
// TODO(api): 以下为原 mock 逻辑(保留,待后端对应接口就绪后替换)
const SIX_DIGIT_CODE = /^\d{6}$/
@@ -18,26 +59,6 @@ export function sendSmsCode(phone: string): Promise<void> {
return Promise.resolve()
}
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
const { account, password } = params
if (!account.trim() || !password) {
return Promise.reject(new Error('请输入账号和密码'))
}
if (account === 'demo') {
const user: User = {
id: 'u1',
nickname: '王永志',
avatar: '',
phone: '186****618',
email: 'yongzh******4@163.com',
passwordModifiedAt: '',
}
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
}
return Promise.reject(new Error('登录失败,请先注册账号'))
}
/** 手机验证码登录(mock:验证码须为 6 位数字) */
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
const { phone, code } = params
@@ -69,19 +90,6 @@ function validateAuthForm(
return null
}
/** 注册 */
export function register(params: RegisterParams): Promise<void> {
const message = validateAuthForm(
params.phone,
params.code,
params.password,
params.confirmPassword,
params.agree,
)
if (message) return Promise.reject(new Error(message))
return Promise.resolve()
}
/** 重置密码 */
export function resetPassword(params: ResetPasswordParams): Promise<void> {
const message = validateAuthForm(

View File

@@ -1,227 +1,90 @@
import type { BusinessType, PageResult } from '@/types/api'
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases'
// 案例库接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.ProjectCaseUserController
// 前缀: /web/projectCase
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不做数据加工、不维护状态。
import { request, serializeQuery } from '@/utils/request'
import type {
CaseSelectionModel,
ProjectCaseAndCaseCountVo,
ProjectCaseUserBo,
ProjectCaseVo,
} from '@/types/business/cases'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */
const MOCK_CASES: Case[] = [
{
id: 'case-001',
title: '自动化生产线升级改造项目',
category: 'manufacturing',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '300万',
industry: '自动化',
duration: '150个工作日',
clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
video: '',
isFavorite: false,
},
{
id: 'case-002',
title: '智能工厂物联网整体解决方案',
category: 'iot',
cover: '',
finishDate: '2026/8/10',
teamSize: 30,
scale: '500万',
industry: '工厂服务',
duration: '200个工作日',
clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
video: '',
isFavorite: false,
},
{
id: 'case-003',
title: '智能穿戴设备PCB设计与量产',
category: 'hardware',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '10-50万',
industry: '智能设备',
duration: '30个工作日',
clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
video: '',
isFavorite: false,
},
{
id: 'case-004',
title: '智能园区综合管理平台开发',
category: 'internet',
cover: '',
finishDate: '2026/8/10',
teamSize: 20,
scale: '200万',
industry: '园区服务',
duration: '200个工作日',
clientType: '企业',
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
video: '',
isFavorite: false,
},
{
id: 'case-005',
title: '电商中台系统定制开发',
category: 'internet',
cover: '',
finishDate: '2026/7/20',
teamSize: 15,
scale: '150万',
industry: '电商零售',
duration: '120个工作日',
clientType: '企业',
background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。',
painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。',
requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。',
deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。',
video: '',
isFavorite: false,
},
{
id: 'case-006',
title: '教培机构在线学习平台',
category: 'internet',
cover: '',
finishDate: '2026/6/30',
teamSize: 12,
scale: '80万',
industry: '教育培训',
duration: '90个工作日',
clientType: '企业',
background: '教培机构需要线上授课、作业批改与学员管理一体化能力。',
painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。',
requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。',
deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。',
video: '',
isFavorite: false,
},
{
id: 'case-007',
title: '医院智能随访系统开发',
category: 'internet',
cover: '',
finishDate: '2026/5/18',
teamSize: 18,
scale: '250万',
industry: '医疗健康',
duration: '180个工作日',
clientType: '企业',
background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。',
painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。',
requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。',
deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。',
video: '',
isFavorite: false,
},
{
id: 'case-008',
title: '园区光伏能耗管理系统',
category: 'iot',
cover: '',
finishDate: '2026/4/25',
teamSize: 10,
scale: '120万',
industry: '能源环保',
duration: '60个工作日',
clientType: '企业',
background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。',
painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。',
requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。',
deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。',
video: '',
isFavorite: false,
},
]
const SCALE_BOUNDS: Record<ScaleRange, [number, number]> = {
lt50w: [0, 50],
'50to100w': [50, 100],
'100to300w': [100, 300],
'300to500w': [300, 500],
gt500w: [500, Number.POSITIVE_INFINITY],
}
/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */
function parseScaleValue(scale: string): number {
const value = Number.parseFloat(scale)
return Number.isNaN(value) ? 0 : value
}
function matchScale(item: Case, selected: ScaleRange[]): boolean {
if (selected.length === 0) return true
const value = parseScaleValue(item.scale)
return selected.some((key) => {
const [min, max] = SCALE_BOUNDS[key]
return value >= min && value < max
/**
* 案例库分页列表(返回列表 + 业务数据统计)。
*
* <p>规模区间是嵌套对象(`scales[0].lowerLimit`),必须用 serializeQuery 序列化——
* axios 默认的 `scales[0][lowerLimit]` 后端绑不上,实测 HTTP 500。</p>
*
* @param bo 筛选条件(businessTypes 按案例表 business_type 原值匹配)
* @param pageNum 页码,从 1 开始(后端 PageQuery)
* @param pageSize 每页条数
*/
export function getCaseList(
bo: ProjectCaseUserBo,
pageNum: number,
pageSize: number,
): Promise<ProjectCaseAndCaseCountVo> {
return request<ProjectCaseAndCaseCountVo>({
url: '/web/projectCase/list',
method: 'get',
params: { ...bo, pageNum, pageSize },
paramsSerializer: { serialize: serializeQuery },
})
}
function matchDate(item: Case, range: [string, string] | null): boolean {
if (!range) return true
const time = new Date(item.finishDate).getTime()
const start = new Date(range[0]).getTime()
const end = new Date(range[1]).getTime()
return time >= start && time <= end
}
function applyFilters(query: CaseQuery): Case[] {
return MOCK_CASES.filter((item) => {
if (query.category !== 'all' && item.category !== query.category) return false
if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false
if (!matchScale(item, query.scales)) return false
if (!matchDate(item, query.dateRange)) return false
if (query.keyword && !item.title.includes(query.keyword)) return false
return true
/**
* 案例详情(含项目背景富文本与附件,并回填当前用户是否已收藏)。
*
* <p>注意:该接口每次调用都会把案例预览量 +1,勿在轮询 / 重试场景滥用。</p>
*/
export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
return request<ProjectCaseVo | null>({
url: `/web/projectCase/${id}`,
method: 'get',
})
}
/** 案例列表(筛选 + 分页) */
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> {
const filtered = applyFilters(query)
const start = (query.page - 1) * query.pageSize
return Promise.resolve({
list: filtered.slice(start, start + query.pageSize),
total: filtered.length,
/**
* 案例收藏 / 取消收藏。
*
* <p>后端为 toggle 语义:已收藏则取消收藏。需登录态,匿名调用会被后端拦截。</p>
*/
export function collectCase(id: string): Promise<void> {
return request<void>({
url: '/web/projectCase/collect',
method: 'post',
data: { id },
})
}
/** 案例详情(不存在返回 null) */
export function getCaseDetail(id: string): Promise<Case | null> {
const found = MOCK_CASES.find((item) => item.id === id) ?? null
return Promise.resolve(found)
/**
* 筛选面板选项(业务类型统计 + 行业 + 规模档位)。
*
* <p>左侧筛选除完成时间外的选项都由本接口一次下发:业务类型与案例总数在
* {@code projectCaseStatisticsVo},行业在 {@code businessIndustry},规模在
* {@code upperAndLowerLimits}(label 已由后端拼好)。</p>
*/
export function getCaseCategories(): Promise<CaseSelectionModel> {
return request<CaseSelectionModel>({
url: '/web/projectCase/categories',
method: 'get',
})
}
/** 收藏/取消收藏,返回最新案例数据 */
export function toggleFavorite(id: string): Promise<Case> {
const found = MOCK_CASES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('案例不存在'))
found.isFavorite = !found.isFavorite
return Promise.resolve({ ...found })
}
/** 业务类型计数(筛选面板角标) */
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
const counts: Partial<Record<BusinessType, number>> = {}
for (const item of MOCK_CASES) {
counts[item.category] = (counts[item.category] ?? 0) + 1
}
return Promise.resolve(counts)
/**
* 案例浏览王(首页「案例库精选」)。
*
* <p>不传 type 时返回「各业务类型的浏览王」各一条;传 type 时按案例表
* business_type 的原值精确匹配,返回该类型全部案例。</p>
*/
export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
return request<ProjectCaseVo[]>({
url: '/web/projectCase/mostViewed',
method: 'get',
params: type ? { type } : undefined,
})
}

View File

@@ -1,25 +1,183 @@
import type { Demand } from '@/types/business/demand'
import { storage } from '@/utils/storage'
// 需求提报接口(真实后端绑定)
// 草稿 com.ruoyi.vermins.controller.pc.web.WorkUserOrderDraftController → /web/workOrderDraft
// 提交 com.ruoyi.vermins.controller.pc.web.ProjectRequirementUserController → /web/requirementPool/create(WorkOrderDraftBo)
// 同控制器的 /submit(RdProjectBo)是直接提报入口(额外落 vm_rd_project),前端当前未使用
// 业务类型 com.ruoyi.vermins.controller.pc.ProjectSubmissionWebController → /web/projectSubmission(@SaIgnore 免登录)
// 预算范围 org.dromara.system.controller.system.SysDictDataController → /system/dict/data/type/{dictType}
// 图片换链 org.dromara.resource.controller.SysOssController → /resource/oss/listByIds/{ids}
// 鉴权:业务类型列表免登录;草稿 / 提交 / 字典 / OSS 需登录态(匿名实测 401,故 /demand 路由带 requiresAuth)
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块;本文件只放「函数签名 + 请求路径」。
import service, { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type {
Demand,
DemandDraftVo,
DictOption,
OssUploadVo,
OssUrlVo,
SubmissionBizTypeVo,
} from '@/types/business/demand'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
/** 业务类型字典类型(sys_dict_type.dict_type)——仅作提报列表为空时的兜底值域 */
export const BIZ_TYPE_DICT = 'vm_biz_type'
/** 预算范围字典类型 */
export const BUDGET_RANGE_DICT = 'vm_budget_range'
const DRAFT_KEY = 'demand-draft'
/**
* 业务类型列表(项目提报表,**免登录**)。
*
* <p>后端 `ProjectSubmissionWebController` 带 `@SaIgnore`,是需求页唯一匿名可用的接口;
* 它与首页 `/web/home-page/service-type-list` 共用同一个 `queryEnabledList()`,只返回
* `enabled = 1` 且未被逻辑删除的记录。</p>
*
* <p>返回项里 `subtitle` 是卡片描述来源、`logoUrl` 是卡片图标来源(实测是 OSS 文件标识,
* 需经 {@link getOssUrls} 换链)。</p>
*/
export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
return request<SubmissionBizTypeVo[]>({
url: '/web/projectSubmission/list',
method: 'get',
})
}
/** 提交需求(mock:返回提报单 id) */
export function submitDemand(demand: Demand): Promise<{ id: string }> {
if (!demand.businessType || !demand.projectName || !demand.coreDescription) {
return Promise.reject(new Error('需求信息不完整,无法提交'))
/**
* OSS 文件标识 → 可直接访问的地址。
*
* <p>提报列表的 `coverUrl` / `logoUrl` 下发的是 OSS 主键(如 2099753406859292674)而非地址,
* 直接当 `<img src>` 用会请求到本站的错误路径上,必须换链。</p>
*
* <p>⚠️ 本接口需要登录态;失败时调用方回退前端内置图标,且不阻塞页面。</p>
*/
export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
return request<OssUrlVo[]>({
url: `/resource/oss/listByIds/${ids.join(',')}`,
method: 'get',
})
}
/**
* 业务类型字典(`vm_biz_type`)。
*
* <p>⚠️ 仅作**兜底**用:业务类型值域已改为提报列表 {@link getSubmissionBizTypes};
* 只有当提报表一条可用记录都没有时,store 才回退到本字典,避免页面无可选项。</p>
*/
export function getBizTypeDict(): Promise<DictOption[]> {
return request<DictOption[]>({
url: `/system/dict/data/type/${BIZ_TYPE_DICT}`,
method: 'get',
})
}
/** 预算范围选项(字典 vm_budget_range) */
export function getBudgetRangeDict(): Promise<DictOption[]> {
return request<DictOption[]>({
url: `/system/dict/data/type/${BUDGET_RANGE_DICT}`,
method: 'get',
})
}
/**
* 读取当前登录用户的需求提报草稿(无草稿返回 null)。
*
* <p>⚠️ 后端 `WorkUserOrderDraftController#getDraft` 在"该用户还没有草稿"时会 NPE:
* `VmWorkOrderDraftServiceImpl#queryById` 里 `draft` 为 null 仍继续执行 `draft.getId()`,
* 实测返回 code=500「发生未知异常」。这里刻意绕过统一 `request`(它会对非 200 弹错误提示),
* 直接用原始实例静默兜底:非 200 一律视作"无草稿"。后端补上判空后本段可简化。</p>
*/
export async function getDraftDemand(): Promise<DemandDraftVo | null> {
const resp = await service.request<ApiResponse<DemandDraftVo>>({
url: '/web/workOrderDraft/getDraft',
method: 'get',
})
if (resp.data?.code !== 200) return null
return resp.data.data ?? null
}
/**
* Demand → 后端 `WorkOrderDraftBo` 请求体。
*
* <p>显式列出 BO 的字段,**剔掉纯前端字段 `budgetManual`**(后端 BO/VO 与两张表都没有
* 「是否手写」的概念,留着虽然会被 Jackson 忽略,但容易被误读成后端支持该标记)。</p>
*
* <p>预算范围只有一个字段可下发:预设档位是字典 dictValue,手写时是用户填的金额文本
* ——后端没有可用的 `budgetCustom`(`WorkOrderDraftBo` 里被注释掉、表也无该列)。</p>
*
* <p>`developmentCycle` 是 Integer,表单未填时是空串,必须归一成 null:空串虽然能靠
* Jackson 的宽松转换塞进去,但依赖默认行为、且语义上是"没填"。</p>
*/
function toBoPayload(demand: Demand): Record<string, unknown> {
return {
id: demand.id,
userName: demand.userName,
userPhone: demand.userPhone,
bizType: demand.bizType,
budgetRange: demand.budgetRange,
developmentCycle: demand.developmentCycle === '' ? null : demand.developmentCycle,
supplementaryRemarks: demand.supplementaryRemarks,
projectName: demand.projectName,
requirementDesc: demand.requirementDesc,
attachmentInfo: demand.attachmentInfo,
}
return Promise.resolve({ id: `demand-${Date.now()}` })
}
/** 读取本地草稿(无草稿返回 null) */
export function getDraftDemand(): Promise<Demand | null> {
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
}
/** 保存草稿到本地 */
/** 新增/覆盖草稿(后端 insertByBo 按登录用户 upsert) */
export function saveDraft(demand: Demand): Promise<void> {
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
return Promise.resolve()
return request<void>({
url: '/web/workOrderDraft/add',
method: 'post',
data: toBoPayload(demand),
})
}
/** 清空当前用户的草稿(后端 cleanHistoryDrafts) */
export function resetDraft(): Promise<void> {
return request<void>({
url: '/web/workOrderDraft/reset',
method: 'post',
})
}
/** 上传附件(multipart,后端字段名 file,返回 SysOssVo) */
export function uploadDemandFile(file: File): Promise<OssUploadVo> {
const data = new FormData()
data.append('file', file)
return request<OssUploadVo>({
url: '/web/workOrderDraft/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
})
}
/**
* 提交需求(`POST /web/requirementPool/create`)。
*
* <p>入参与草稿共用同一个 BO(`WorkOrderDraftBo`),故请求体直接复用 {@link toBoPayload},
* 不需要字段名转换。</p>
*
* <p>后端 `createRequirement` 一个事务里做三件事:</p>
* <ul>
* <li>落 `vm_project_requirement`(需求主记录,生成项目编号、优先级=未评估、状态=待接洽);</li>
* <li>把**请求体里的 `attachmentInfo`** 逐条复制成需求附件(只复制 `ossId` 引用到
* `vm_project_requirement_attachment`,OSS 文件不重复上传)——注意这里是直接吃请求体,
* 不像 `/submit` 那样还得先落一次草稿,所以附件随本次请求一起走;</li>
* <li>清掉该用户的草稿(`cleanHistoryDrafts`)。</li>
* </ul>
*
* <p>后端必填校验:`bizType` / `projectName` / `requirementDesc` 任一为空返回
* 「请完整填写业务类型、项目名称和需求描述」;联系人姓名与电话缺失时先回退登录用户资料,
* 仍为空则报「请完善联系人姓名和联系电话」。</p>
*
* <p>⚠️ 本条链路**只落 `vm_project_requirement` 单表**。而「我的项目/需求」列表
* (`/web/requirementPool/list`)查的是 `vm_rd_project`,因此 create 提交的记录不会出现在
* 该列表里——要同时落两张表得走后端 `/web/requirementPool/submit`(`RdProjectBo`)。</p>
*/
export function submitDemand(demand: Demand): Promise<void> {
return request<void>({
url: '/web/requirementPool/create',
method: 'post',
data: toBoPayload(demand),
})
}

View File

@@ -1,180 +1,97 @@
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home'
// 首页接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
// 门户入口: com.ruoyi.vermins.controller.pc.web.IPortalEntryUserController(首页快捷入口功能,并入本文件)
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
import { request } from '@/utils/request'
import type {
AgreementFormVo,
BannerVo,
PortalEntryGroup,
PortalEntryVo,
ProjectStatusCount,
ProjectSubmissionInfo,
ProjectSubmissionVo,
} from '@/types/business/home'
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变)
const SERVICE_TYPES: ServiceType[] = [
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' },
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' },
{ label: 'APP', desc: 'iOS/Android 移动应用定制开发' },
{ label: '二次开发', desc: '基于现有系统的功能扩展与重构' },
{ label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' },
]
const SERVICE_CONTENT = `一、产品咨询与规划服务
1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。
2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。
3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。
4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。
二、UI/UX 设计服务
1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。
2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。
3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。
4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。
三、技术开发服务
5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。
6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。
7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。
8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。
四、质量保障(QA)服务
1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。
2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。
3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。
4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。
五、运维与持续运营服务
1、7x24小时监控:实时监控系统健康度、流量及异常日志。
2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。
3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。`
const BUSINESS_LINES: BusinessLine[] = [
{
id: 'internet',
name: '互联网软件',
desc: 'Web/小程序/APP/二次开发/桌面工具等',
slogan: '专业的软件定制开发服务,从需求到上线全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'hardware',
name: '硬件电路',
desc: '原理图设计/PCB设计/电气效验等',
slogan: '专业的硬件电路设计服务,从方案到量产全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'iot',
name: '物联网全案',
desc: '软硬件开发+云端一体化/定制化开发等',
slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'manufacturing',
name: '生产制造',
desc: 'PCB制版/SMT/整机装配/定制生产等',
slogan: '专业的研发智造生产服务,从制版到交付全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
]
const FEATURED_CASES: Case[] = [
{
id: 'case-001',
title: '自动化生产线升级改造项目',
category: 'manufacturing',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '300万',
industry: '自动化',
duration: '150个工作日',
clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
video: '',
isFavorite: false,
},
{
id: 'case-002',
title: '智能工厂物联网整体解决方案',
category: 'iot',
cover: '',
finishDate: '2026/8/10',
teamSize: 30,
scale: '500万',
industry: '工厂服务',
duration: '200个工作日',
clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
video: '',
isFavorite: false,
},
{
id: 'case-003',
title: '智能穿戴设备PCB设计与量产',
category: 'hardware',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '10-50万',
industry: '智能设备',
duration: '30个工作日',
clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
video: '',
isFavorite: false,
},
{
id: 'case-004',
title: '智能园区综合管理平台开发',
category: 'internet',
cover: '',
finishDate: '2026/8/10',
teamSize: 20,
scale: '200万',
industry: '园区服务',
duration: '200个工作日',
clientType: '企业',
background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。',
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。',
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
video: '',
isFavorite: false,
},
]
/** 首页数据看板 */
export function getHomeStats(): Promise<HomeStats> {
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
/** 项目状态统计 */
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
url: '/web/home-page/project-status-count',
method: 'get',
})
}
/** 业务线列表(首页 4 卡片) */
export function getBusinessLines(): Promise<BusinessLine[]> {
return Promise.resolve(BUSINESS_LINES)
/** 业务服务类型列表 */
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
return request<ProjectSubmissionVo[]>({
url: '/web/home-page/service-type-list',
method: 'get',
})
}
/** 业务详情(业务详情页) */
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
const found = BUSINESS_LINES.find((line) => line.id === type)
return Promise.resolve(found ?? BUSINESS_LINES[0]!)
/** 业务类型详情(聚合 VO:projectSubmission / projectServiceType / projectSubmissionAttachment / projectSubmissionCatalog) */
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionInfo> {
return request<ProjectSubmissionInfo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
}
/** 首页案例精选(category=all 时返回全部) */
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> {
const list = category === 'all'
? FEATURED_CASES
: FEATURED_CASES.filter((item) => item.category === category)
return Promise.resolve(list)
/** 轮播图列表 */
export function getCarouselList(): Promise<BannerVo[]> {
return request<BannerVo[]>({
url: '/web/home-page/carousel-list',
method: 'get',
})
}
/** 平台介绍(富文本 HTML) */
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
/** 联系信息(富文本 HTML) */
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}
// 首页「案例库精选」接口(/web/projectCase/mostViewed)按资源归位到 `@/api/cases` 的 getFeaturedCases。
/**
* 查询用户门户入口列表
*
* <p>登录态返回该用户个性化后的分组结果;匿名返回固定目录。
* 两组数据由 groupType 区分(1 = 快捷、0 = 其他)。</p>
*/
export function getPortalEntryList(): Promise<PortalEntryVo[]> {
return request<PortalEntryVo[]>({
url: '/vermins/portalEntryUser/list',
method: 'get',
})
}
/**
* 快捷 / 其他入口互转
*
* <p>后端 add 的入参是 @RequestParam,必须走 query 而非 body。</p>
*
* @param id 入口 id
* @param type 目标分组:1 = 快捷、0 = 其他
*/
export function switchPortalEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
return request<void>({
url: '/vermins/portalEntryUser/add',
method: 'post',
params: { id, type },
})
}

View File

@@ -168,12 +168,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '自动化',
duration: '150个工作日',
clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
sections: [
{ title: '项目背景', content: '<p>传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。</p>' },
{ title: '客户痛点', content: '<p>1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。</p>' },
{ title: '需求描述', content: '<p>建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。</p>' },
{ title: '交付物', content: '<p>1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。</p>' },
],
video: '',
isFavorite: true,
favoriteCount: 0,
},
{
id: 'fav-2',
@@ -186,12 +189,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '工厂服务',
duration: '200个工作日',
clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
sections: [
{ title: '项目背景', content: '<p>园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。</p>' },
{ title: '客户痛点', content: '<p>1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。</p>' },
{ title: '需求描述', content: '<p>构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。</p>' },
{ title: '交付物', content: '<p>1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。</p>' },
],
video: '',
isFavorite: true,
favoriteCount: 0,
},
{
id: 'fav-3',
@@ -204,12 +210,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '智能设备',
duration: '30个工作日',
clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
sections: [
{ title: '项目背景', content: '<p>客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。</p>' },
{ title: '客户痛点', content: '<p>1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。</p>' },
{ title: '需求描述', content: '<p>完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。</p>' },
{ title: '交付物', content: '<p>1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。</p>' },
],
video: '',
isFavorite: true,
favoriteCount: 0,
},
{
id: 'fav-4',
@@ -222,12 +231,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '园区服务',
duration: '200个工作日',
clientType: '企业',
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
sections: [
{ title: '项目背景', content: '<p>传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。</p>' },
{ title: '客户痛点', content: '<p>1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。</p>' },
{ title: '需求描述', content: '<p>打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。</p>' },
{ title: '交付物', content: '<p>1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。</p>' },
],
video: '',
isFavorite: true,
favoriteCount: 0,
},
]

View File

@@ -1,13 +1,124 @@
import type { FileInfo, PageResult } from '@/types/api'
import type { FileInfo } from '@/types/api'
import type {
AfterSales,
AfterSalesPayload,
Project,
ProjectListResult,
ProjectQuery,
ProjectStatistics,
RequirementDetailVo,
} from '@/types/business/project'
import { request } from '@/utils/request'
import { formatDate } from '@/utils/format'
import { toProjectDetail } from '@/utils/projectVo'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
// ─────────────────────────────────────────────────────────────────────────────
// 列表:真实接口
// GET /web/requirementPool/list → ProjectRequirementUserController#list
// → ProjectRequirementServiceImpl#queryUserPageList
// 入参 RdProjectBo + PageQuery;出参 ProjectRequirementAndCountVo
// { requirementPage: { total, rows: RdProjectVo[] }, requirementStatistics: null }
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
// 筛选参数实测(tools/probe_project_api.py):
// key → like projectName OR projectNo OR requirementDesc
// businessType → eq business_type(值是提报表主键 id,不是类型名)
// status → eq status(码值,见 RdProjectStatus)
// beginTime/endTime → between create_time(不是 submit_time!)
// pageNum/pageSize → PageQuery
// ⚠️ 主表已改为**需求表** vm_project_requirement(LEFT JOIN 项目表),未立项的需求也会出现;
// 未立项行 progress 为 null(显示「—」)、status 回落需求表中的 INITIATING。
// ⚠️ 列表行的 businessType 是 SQL 里 JOIN 提报表**翻译后的中文名称**(如「互联网软件」),
// 与筛选参数 businessType(提报表 id)**不是同一口径**,渲染时不能用 typeCounts 反查。
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// 业务类型筛选条件:真实接口
// GET /web/requirementPool/getFilterConditions
// → ProjectRequirementUserController#getFilterConditions
// → ProjectRequirementServiceImpl#getFilterConditions
// 出参 ProjectRequirementStatisticsVo:
// { total: 该用户需求总数,
// businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }] }
// 口径:**全量、不分页**,所以件数不会随 pageNum/pageSize 变化,适合当筛选栏的固定值域;
// businessTypeId = vm_project_submission 主键 id(直接回传当 businessType 筛选参数);
// businessTypeLabel = 该表的 business_type 名称;提报表里没命中的脏数据不计入。
// ⚠️ /list 响应里的 requirementStatistics 后端仍是注释状态(实测恒 null),统计一律走本端点。
// ─────────────────────────────────────────────────────────────────────────────
/** 两位补零 */
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/** Date → 'yyyy-MM-dd HH:mm:ss'(后端 RdProjectBo.beginTime/endTime 是 Date,实测该格式可绑定) */
function toDateTime(date: Date, endOfDay: boolean): string {
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
}
/**
* 提交时间条件 → 后端 `beginTime` / `endTime`(都落在 `create_time` 上)。
*
* <p>自选区间优先(UI 上两组互斥,这里再兜一层);结束日一律补到 23:59:59,
* 否则 `endTime=2026-09-30` 会被绑成当天 00:00:00 而漏掉当天数据。</p>
*/
function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
if (query.startDate || query.endDate) {
return {
beginTime: query.startDate ? `${query.startDate} 00:00:00` : undefined,
endTime: query.endDate ? `${query.endDate} 23:59:59` : undefined,
}
}
if (query.timeShortcut === 'all') return {}
const start = new Date()
if (query.timeShortcut === '3m') start.setMonth(start.getMonth() - 3)
else if (query.timeShortcut === '6m') start.setMonth(start.getMonth() - 6)
else start.setFullYear(start.getFullYear() - 1)
return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
}
/** 项目列表(真实接口:服务端筛选 + 分页;业务类型统计走 getProjectFilterConditions) */
export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
return request<ProjectListResult>({
url: '/web/requirementPool/list',
method: 'get',
params: {
pageNum: query.pageNum,
pageSize: query.pageSize,
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
key: query.keyword || undefined,
businessType: query.businessType || undefined,
status: query.status || undefined,
...toTimeRange(query),
},
})
}
/**
* 业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
*
* <p>与列表分开取:`/list` 的 `requirementStatistics` 后端恒为 null,统计由本端点单独下发,
* 且是**全量口径**(不随筛选、分页变化),所以进页面拉一次即可。</p>
*/
export function getProjectFilterConditions(): Promise<ProjectStatistics> {
return request<ProjectStatistics>({
url: '/web/requirementPool/getFilterConditions',
method: 'get',
})
}
// ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口
// GET /web/requirementPool/get/{id} → ProjectRequirementUserController#get
// → ProjectRequirementServiceImpl#queryRequirementByUserId
// 出参 ProjectRequirementUserVo,经 utils/projectVo.ts 映射成详情页沿用的 Project 形态。
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id。
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 data 为 null。
//
// 文件 / 售后:**仍是前端 mock** —— 该 Controller 只有 create / list / get /
// serviceTypeList 四个接口,没有附件上传、售后提交/取消的后端实现。
// ─────────────────────────────────────────────────────────────────────────────
/** 构造 mock 文件信息 */
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
@@ -17,7 +128,7 @@ function mockFile(name: string, size: number, uploadTime: string): FileInfo {
/** 详情 mock 所属项目 id */
const DETAIL_ID = 'project-004'
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合)——仅详情页链路在用 */
const MOCK_PROJECTS: Project[] = [
{
id: 'project-001',
@@ -176,49 +287,15 @@ const PROJECT_DETAIL: Project = {
},
}
/** 提交时间快捷区间匹配 */
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean {
if (shortcut === 'all') return true
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析
const time = new Date(submitTime.replaceAll('-', '/')).getTime()
const start = new Date()
if (shortcut === '3m') start.setMonth(start.getMonth() - 3)
if (shortcut === '6m') start.setMonth(start.getMonth() - 6)
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1)
return time >= start.getTime()
}
/** 项目列表(筛选 + 分页,pageSize 固定 8) */
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> {
const filtered = MOCK_PROJECTS.filter((item) => {
if (
query.keyword
&& !item.projectName.includes(query.keyword)
&& !item.projectNo.includes(query.keyword)
) {
return false
}
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false
if (query.status !== 'all' && item.status !== query.status) return false
if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false
return true
})
const pageSize = query.pageSize > 0 ? query.pageSize : 8
const start = (query.page - 1) * pageSize
return Promise.resolve({
list: filtered.slice(start, start + pageSize),
total: filtered.length,
})
}
/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */
/** 项目详情(真实接口;不存在 / 非本人需求返回 null) */
export function getProjectDetail(id: string): Promise<Project | null> {
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL })
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found } : null)
return request<RequirementDetailVo | null>({
url: `/web/requirementPool/get/${id}`,
method: 'get',
}).then((vo) => (vo ? toProjectDetail(vo) : null))
}
/** 上传项目文件(追加进详情文件列表) */
/** 上传项目文件(mock:追加进详情文件列表) */
export function uploadProjectFile(file: File): Promise<FileInfo> {
const info: FileInfo = {
name: file.name,
@@ -230,7 +307,7 @@ export function uploadProjectFile(file: File): Promise<FileInfo> {
return Promise.resolve({ ...info })
}
/** 删除项目文件(按索引从详情文件列表移除) */
/** 删除项目文件(mock:按索引从详情文件列表移除) */
export function deleteProjectFile(index: number): Promise<void> {
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
PROJECT_DETAIL.files.splice(index, 1)
@@ -238,7 +315,7 @@ export function deleteProjectFile(index: number): Promise<void> {
return Promise.resolve()
}
/** 提交售后工单 */
/** 提交售后工单(mock) */
export function submitAfterSales(
projectId: string,
payload: AfterSalesPayload,
@@ -255,7 +332,7 @@ export function submitAfterSales(
return Promise.resolve({ ...result, attachments: [...result.attachments] })
}
/** 取消售后工单 */
/** 取消售后工单(mock) */
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
const result: AfterSales = {
status: '无',

View File

@@ -5,10 +5,10 @@ const siteStore = useSiteStore()
const quickLinks = [
{ label: '首页', path: '/' },
{ label: '案例中心', path: '/cases' },
{ label: '需求提报', path: '/demand' },
{ label: '案例库', path: '/cases' },
{ label: '需求池', path: '/demand' },
{ label: '下载 APP', path: '/' },
{ label: '帮助中心', path: '/profile/help' },
{ label: '消息中心', path: '/message' },
]
const coreBusiness = [
@@ -40,6 +40,17 @@ const coreBusiness = [
</p>
</div>
<div class="app-footer__col">
<h4 class="app-footer__title">
核心业务
</h4>
<span
v-for="item in coreBusiness"
:key="item"
class="app-footer__link"
>{{ item }}</span>
</div>
<div class="app-footer__col">
<h4 class="app-footer__title">
快速导航
@@ -54,17 +65,6 @@ const coreBusiness = [
</RouterLink>
</div>
<div class="app-footer__col">
<h4 class="app-footer__title">
核心业务
</h4>
<span
v-for="item in coreBusiness"
:key="item"
class="app-footer__link"
>{{ item }}</span>
</div>
<div class="app-footer__col">
<h4 class="app-footer__title">
联系我们

View File

@@ -11,7 +11,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ label: '首页', path: '/' },
{ label: '案例库', path: '/cases' },
{ label: '需求提报', path: '/demand' },
{ label: '需求池', path: '/demand' },
{ label: '项目中心', path: '/project' },
{ label: '合同中心', path: '/contract' },
{ label: '财务中心', path: '/finance' },
@@ -45,6 +45,12 @@ onMounted(() => void messageStore.fetchUnreadCount())
</nav>
<div class="app-header__actions">
<RouterLink to="/demand" class="app-header__action app-header__action--primary">
快速提报
</RouterLink>
<RouterLink to="/service" class="app-header__action app-header__action--success">
在线客服
</RouterLink>
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心">
<span class="app-header__bell-icon">🔔</span>
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
@@ -108,6 +114,28 @@ onMounted(() => void messageStore.fetchUnreadCount())
gap: @spacing-base;
}
&__action {
.flex(row, center);
height: 28px;
padding: 0 @spacing-base;
border-radius: @radius-round;
color: @white;
font-size: @font-size-sm;
transition: opacity 0.2s;
&:hover {
opacity: 0.88;
}
&--primary {
background: linear-gradient(90deg, @warning-color, @danger-color);
}
&--success {
background: linear-gradient(90deg, #22b14c, @success-color);
}
}
&__bell {
position: relative;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatBusinessType } from '@/utils/format'
import type { Case } from '@/types/business/cases'
@@ -6,36 +7,60 @@ interface Props {
item: Case
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
const emit = defineEmits<{
view: [id: string]
favorite: [id: string]
}>()
function onView(id: string): void {
emit('view', id)
}
/** 卡片元信息:设计稿为「标签:值」三列两行 */
const meta = computed<Array<{ label: string; value: string }>>(() => [
{ label: '客户类型', value: props.item.clientType || '--' },
{ label: '所属行业', value: props.item.industry || '--' },
{ label: '规模', value: props.item.scale || '--' },
{ label: '项目周期', value: props.item.duration || '--' },
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
// 设计稿完成时间为 2026/8/10 形式,后端 completedAt 下发 2026-08-10
{ label: '完成时间', value: props.item.finishDate ? props.item.finishDate.replace(/-/g, '/') : '--' },
])
</script>
<template>
<article class="case-card" @click="onView(item.id)">
<article class="case-card" @click="emit('view', item.id)">
<div class="case-card__cover">
<!-- TODO(蓝湖): 替换案例封面切图,接入 v-lazy -->
<!-- TODO(蓝湖): 替换案例封面切图 -->
<img v-if="item.cover" v-lazy="item.cover" class="case-card__img" :alt="item.title">
<div v-else class="case-card__img case-card__img--placeholder" />
<span class="case-card__tag">{{ formatBusinessType(item.category) }}</span>
<span class="case-card__more">查看详情 ></span>
<span class="case-card__tag" :class="`case-card__tag--${item.category}`">
{{ formatBusinessType(item.category) }}
</span>
<button
class="case-card__fav"
:class="{ 'is-active': item.isFavorite }"
:aria-label="item.isFavorite ? '取消收藏' : '收藏案例'"
@click.stop="emit('favorite', item.id)"
>
<svg class="case-card__fav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.2-4.6-8.4-9A5.1 5.1 0 0 1 12 5.6a5.1 5.1 0 0 1 8.4 5.9c-1.2 4.4-8.4 9-8.4 9Z" />
</svg>
</button>
</div>
<div class="case-card__body">
<h3 class="case-card__title">
{{ item.title }}
</h3>
<ul class="case-card__meta">
<li>完成时间:{{ item.finishDate }}</li>
<li>服务团队:{{ item.teamSize }}人</li>
<li>规模:{{ item.scale }}</li>
<li>所属行业:{{ item.industry }}</li>
<li>项目周期:{{ item.duration }}</li>
<li>客户类型:{{ item.clientType }}</li>
<li v-for="row in meta" :key="row.label">
<span class="case-card__label">{{ row.label }}:</span>{{ row.value }}
</li>
</ul>
<span class="case-card__more">查看详情 ></span>
</div>
</article>
</template>
@@ -43,25 +68,22 @@ function onView(id: string): void {
<style lang="less" scoped>
.case-card {
overflow: hidden;
border-radius: @radius-lg;
border-radius: @radius-base;
background: @white;
box-shadow: @shadow-base;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
.case-card__more {
opacity: 1;
}
}
&__cover {
position: relative;
height: 160px;
margin: @spacing-sm @spacing-sm 0;
border-radius: @radius-base;
overflow: hidden;
aspect-ratio: 16 / 5;
}
&__img {
@@ -76,50 +98,104 @@ function onView(id: string): void {
&__tag {
position: absolute;
top: @spacing-sm;
left: @spacing-sm;
padding: 2px @spacing-sm;
border-radius: @radius-round;
background: rgba(0, 0, 0, 0.55);
color: @white;
font-size: @font-size-sm;
}
&__more {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
padding: 2px @spacing-sm;
top: 10px;
left: 10px;
height: 24px;
padding: 0 10px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
opacity: 0;
transition: opacity 0.2s;
line-height: 24px;
&--hardware {
background: @tag-hardware;
}
&--iot {
background: @tag-iot;
}
&--manufacturing {
background: @tag-manufacturing;
}
&--other {
background: @tag-other;
}
}
&__fav {
position: absolute;
top: 10px;
right: 10px;
.flex(row, center);
width: 28px;
height: 28px;
border-radius: @radius-round;
background: rgba(255, 255, 255, 0.85);
transition: background 0.2s;
&:hover {
background: @white;
}
&-icon {
width: 15px;
height: 15px;
fill: none;
stroke: @text-color-secondary;
stroke-width: 1.8;
transition: all 0.2s;
}
&.is-active {
background: @white;
.case-card__fav-icon {
fill: @danger-color;
stroke: @danger-color;
}
}
}
&__body {
padding: @spacing-base;
padding: @spacing-sm @spacing-base @spacing-base;
}
&__title {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.multi-ellipsis(1);
.ellipsis();
}
&__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px @spacing-sm;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px @spacing-sm;
margin-top: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
line-height: 1.5;
color: @heading-color;
li {
.ellipsis();
/* 值可能较长(如「完成时间:2026/11/30」),换行而非截断,避免丢信息 */
overflow-wrap: anywhere;
}
}
&__label {
color: @text-color-secondary;
}
&__more {
display: block;
width: fit-content;
margin: @spacing-base 0 0 auto;
color: @primary-color;
font-size: @font-size-base;
font-weight: 500;
}
}
</style>

View File

@@ -1,15 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { AppFooter, AppHeader } from '@/components'
import FloatingActions from './components/FloatingActions.vue'
const route = useRoute()
/** 公共底栏只在首页展示(首页路由 meta.footer = true),其余页面不渲染 */
const showFooter = computed(() => route.meta.footer === true)
</script>
<template>
<div class="layout">
<AppHeader />
<main class="layout__main">
<main
class="layout__main"
:class="{ 'layout__main--full': !showFooter }"
>
<router-view />
</main>
<AppFooter />
<AppFooter v-if="showFooter" />
<FloatingActions />
</div>
</template>
@@ -18,6 +28,11 @@ import FloatingActions from './components/FloatingActions.vue'
.layout {
.layout__main {
min-height: calc(100vh - @header-height - @footer-height);
// 无底栏的页面:底栏不渲染,这里把它的占位高度还给内容区,避免页面底部空出一屏
&--full {
min-height: calc(100vh - @header-height);
}
}
}
</style>

View File

@@ -10,11 +10,12 @@ const layoutRoutes: RouteRecordRaw = {
component: () => import('@/layout/index.vue'),
children: [
// ── 公开 ──
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报' } },
// 需求提报依赖登录态:字典 / 业务类型 / 草稿 / 上传 / 提交接口匿名实测均 401
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true } },
// ── 登录区 ──
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },

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> {
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 {

View File

@@ -35,3 +35,26 @@
background: transparent;
}
}
// 表单文本控件(输入框 / 文本域共用外观;需求提报页设计稿规格:白底 + 浅灰描边 + 4px 圆角)
.text-control() {
width: 100%;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
font-size: @font-size-base;
font-family: inherit;
color: @heading-color;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}

View File

@@ -1,8 +1,8 @@
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
// 品牌色
@primary-color: #3370ff;
// 品牌色(按案例库设计稿标注校准)
@primary-color: #276fff;
@primary-hover: #598bff;
@primary-light: #eaf1ff;
@@ -46,11 +46,31 @@
@font-size-xl: 20px;
@font-size-xxl: 28px;
// 布局
@container-width: 1200px;
// 布局(容器宽度按设计稿标注校准:设计稿为约 1400 宽的整幅版面)
@container-width: 1400px;
@header-height: 64px;
@footer-height: 240px;
// 阴影
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
// 案例库(按设计稿标注校准;筛选面板 / 案例卡片专用)
@filter-selected-bg: #e6f7ff; // 业务类型选中行底色
@pill-bg: #f2f2f2; // 胶囊选项默认底色
@pill-selected-bg: #d4e2ff; // 胶囊选项选中底色
@chips-panel-bg: #eff6ff; // 「已选条件」区底色
@input-bg: #f3f4f6; // 搜索框 / 日期区间底色
@tag-hardware: #82b43b; // 业务标签:硬件电路
@tag-iot: #5720ba; // 业务标签:物联网全案
@tag-manufacturing: #ff8f1f; // 业务标签:生产制造
@tag-other: #8a94a6; // 业务标签:其他
// 需求提报(按需求提报设计稿标注校准)
@demand-selected-bg: #dee8fc; // 业务类型卡片 / 预算胶囊选中底色
@demand-accent: #2563eb; // 业务类型选中描边与右上角圆点
@demand-secondary-bg: #e6e6e6; // 底部「保存草稿 / 重置」按钮底色
// 项目中心(按项目中心设计稿标注校准)
@project-table-head-bg: #e8e8e8; // 表头底色(设计稿实取 (232,232,232))
@project-table-line: #bbbbbb; // 表格网格线(设计稿实取 (187,187,187))

View File

@@ -1,9 +1,52 @@
// 案例实体(PRD 4.1,home/cases/profile.favorites 共享)
// 案例库类型(案例实体 + 后端 VO,与 api/cases.ts 对应)
//
// 后端:vermis-maker com.ruoyi.vermins.controller.pc.web.ProjectCaseUserController(前缀 /web/projectCase)
// 说明:案例实体类型统一定义在本文件(含首页「案例库精选」消费的 ProjectCaseVo)。
import type { BusinessType } from '@/types/api'
/** 项目规模区间 */
export type ScaleRange = 'lt50w' | '50to100w' | '100to300w' | '300to500w' | 'gt500w'
/**
* 业务类型筛选项。
*
* <p>选项不是前端写死的,而是取自 GET /web/projectCase/categories 返回的
* {@link ProjectCaseStatisticsVo#projectCaseCounts}——后端按项目提报表统计后下发。</p>
*/
export interface CaseTypeOption {
/**
* 下发值 = **提报主键 id**(如 "2099753433048526850")。
*
* <p>必须用 id 而非类型名:案例表 business_type 列实测存的就是提报主键 id,
* 传类型名(如「硬件电路」)后端按原值精确匹配不到 → 实测 total=0。</p>
*/
value: string
/** 展示名(后端下发的 businessType 名称,如「硬件电路」) */
label: string
/** 该业务类型的案例数(后端统计口径,可能为 0) */
count: number
}
/** 案例附件(页面消费,由后端 ProjectCaseAttachmentVo 映射而来) */
export interface CaseAttachment {
/** 原始文件名 */
name: string
/** 文件大小(后端已格式化的文本形式,可能为空) */
size: string
/** 附件访问地址 */
url: string
}
/**
* 案例正文区块(后端「自定义目录」一项 = 页面一张白卡)。
*
* <p>标题由后端下发,前端不写死:增删区块(项目背景 / 客户痛点 / 需求描述 / 交付物 …)
* 完全由 vm_project_case_catalog 数据决定。</p>
*/
export interface CaseSection {
/** 区块标题(vm_project_case_catalog.title) */
title: string
/** 区块内容:富文本 HTML(可能内嵌 img / video) */
content: string
}
/** 案例 */
export interface Case {
@@ -18,21 +61,219 @@ export interface Case {
industry: string
duration: string
clientType: string
background: string
painPoints: string
requirements: string
deliverables: string
/**
* 正文区块列表(标题 + 富文本)。
*
* <p>来自后端 catalogs(vm_project_case_catalog,按 sort_order 升序);
* 列表 / 精选接口不下发,故那些场景为空数组。</p>
*/
sections: CaseSection[]
/** 附件列表(仅案例详情接口返回,列表页为空) */
attachments?: CaseAttachment[]
video: string
isFavorite: boolean
/** 收藏量(详情/列表接口下发,本地收藏翻转时同步 ±1) */
favoriteCount: number
}
/** 案例筛选条件 */
/**
* 案例筛选条件。
*
* <p>三类选项全部由后端下发,前端不写死,且都来自 GET /categories({@link CaseSelectionModel}):
* 业务类型({@link CaseTypeOption})、所属行业、项目规模;仅完成日期区间由前端生成。</p>
*/
export interface CaseQuery {
category: BusinessType | 'all'
/** 业务类型**提报主键 id**(多选,空数组 = 全部;值域见 CaseTypeOption.value) */
categories: string[]
/** 所属行业名称(多选,空数组 = 全部;值域见 BusinessIndustryOption.name) */
industries: string[]
scales: ScaleRange[]
/** 项目规模档位(多选,空数组 = 全部;整档对象直接下发,值域见 /categories) */
scales: ScaleLimit[]
dateRange: [string, string] | null
/** 只看收藏(对应后端 isFavorite=1) */
favoriteOnly: boolean
keyword: string
page: number
pageSize: number
}
/* ============================ 后端 VO / BO ============================ */
/**
* GET /web/projectCase/list 的查询条件(后端 ProjectCaseUserBo)。
*
* <p>businessTypes 按案例表 business_type 列的**原值**精确匹配(in),而该列实测存的是
* **提报主键 id**(非类型名),所以前端下发的是 CaseTypeOption.value(id)。</p>
*/
export interface ProjectCaseUserBo {
/** 业务类型提报主键 id 列表 */
businessTypes?: string[]
/** 业务/行业名称列表 */
businessIndustries?: string[]
/** 规模区间列表(区间之间取并集) */
scales?: ScaleLimit[]
/** 只看收藏:1 = 是;不传 = 全部 */
isFavorite?: number
/** 关键字(案例名 / 客户类型 / 项目周期 / 团队人数) */
key?: string
/** 开始时间 */
beginTime?: string
/** 结束时间 */
endTime?: string
}
/** 规模区间(后端 UpperAndLowerLimitsModel):任一端为空表示该方向不设限 */
export interface ScaleLimit {
lowerLimit?: number | null
upperLimit?: number | null
/** 展示样式:以下 / 区间 / 以上 */
model?: string | null
/** 档位文字(后端拼好,如「28–46万」) */
label?: string | null
sort?: number | null
}
/** 所属行业选项(后端 BusinessIndustryModel) */
export interface BusinessIndustryOption {
name: string | null
sort: number | null
}
/**
* GET /web/projectCase/categories —— 筛选面板选项。
*
* <p>左侧筛选**除完成时间外**的选项全部由本接口一次下发:
* 业务类型({@link CaseSelectionModel.projectCaseStatisticsVo})、
* 所属行业({@link CaseSelectionModel.businessIndustry})、
* 项目规模({@link CaseSelectionModel.upperAndLowerLimits})。</p>
*/
export interface CaseSelectionModel {
businessIndustry: BusinessIndustryOption[] | null
upperAndLowerLimits: ScaleLimit[] | null
/** 业务类型统计(承载业务类型筛选项与案例总数) */
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
}
/** 案例总数 + 各业务类型案例数 */
export interface ProjectCaseStatisticsVo {
/** 案例总数(口径:未删除且在架) */
total: number | null
/** 各业务类型的案例数(按案例数倒序) */
projectCaseCounts: ProjectCaseCountVo[] | null
}
/** 单个业务类型的案例数 */
export interface ProjectCaseCountVo {
/** 业务类型原值(提报主键 id 的字符串形式) */
id: string | null
/** 业务类型名称 */
businessType: string | null
caseCount: number | null
}
/**
* GET /web/projectCase/list —— 案例分页列表 + 业务数据统计。
*
* <p>统计口径是「本次查询结果」,即跟随筛选条件变化,不是全表统计。</p>
*/
export interface ProjectCaseAndCaseCountVo {
projectCasePage: ProjectCasePageVo | null
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
}
/** 分页体(后端 PageResult:rows + total) */
export interface ProjectCasePageVo {
total: number
rows: ProjectCaseVo[]
}
/** 案例库视图对象(列表 / 详情 / mostViewed 共用;后端 Long 主键序列化为字符串) */
export interface ProjectCaseVo {
id: string
/** 案例编号 */
caseNo: string | null
/** 案例名称 */
caseName: string | null
/** 业务类型原值(案例表该列存提报主键 id 或类型名称) */
businessType: string | null
/** 业务类型名称(后端 JOIN 提报表翻译后的展示名) */
projectTypeName: string | null
/** 业务/行业 */
businessIndustry: string | null
/** 上下架:1 上架,0 下架 */
enabled: number | null
/** 规模数值(单位:万) */
scale: number | null
/** 预览量(详情接口每次调用自增 1) */
previewCount: number | null
/** 浏览量(已废弃,勿用于排序) */
viewCount: number | null
/** 收藏量 */
favoriteCount: number | null
/** 封面(后端下发的是 OSS 文件标识,未必是可直接访问的 URL) */
coverUrl: string | null
/** 客户类型 */
customerType: string | null
/** 所属行业 */
industry: string | null
/** 项目周期 */
projectCycle: string | null
/** 服务团队人数 */
serviceTeam: number | null
/** 完成时间 yyyy-MM-dd */
completedAt: string | null
/** 是否收藏:1 是,0 否(详情与列表接口均会回填) */
isFavorite: number | null
/**
* 项目背景富文本段列表(仅详情返回)。
*
* @deprecated 后端已改用 {@link ProjectCaseVo.catalogs}(带标题的自定义目录),该字段恒为 null。
*/
backgrounds: ProjectCaseBackgroundVo[] | null
/** 正文区块列表(仅详情返回;后端按 sort_order、id 升序下发) */
catalogs: ProjectCaseCatalogVo[] | null
/** 附件列表(仅详情返回) */
attachments: ProjectCaseAttachmentVo[] | null
/** 更新时间 yyyy-MM-dd HH:mm:ss */
updateTime: string | null
}
/**
* 案例项目背景富文本段(vm_project_case_detail)。
*
* @deprecated 已被 {@link ProjectCaseCatalogVo} 取代(新结构带 title),后端恒下发 null。
*/
export interface ProjectCaseBackgroundVo {
id: string | null
projectId: string | null
/** 富文本 HTML */
content: string | null
sortOrder: number | null
}
/** 案例自定义目录 / 正文区块(vm_project_case_catalog) */
export interface ProjectCaseCatalogVo {
id: string | null
projectId: string | null
/** 目录标题(如「项目背景」「客户痛点」) */
title: string | null
/** 目录内容富文本 HTML */
content: string | null
/** 排序值,升序 */
sortOrder: number | null
}
/** 案例附件(vm_project_case_attachment) */
export interface ProjectCaseAttachmentVo {
id: string | null
projectId: string | null
/** OSS 标识 */
ossId: string | null
/** 原始文件名 */
originalName: string | null
/** 文件大小(文本形式) */
fileSize: string | null
/** 附件访问地址 */
url: string | null
sortOrder: number | null
}

View File

@@ -1,20 +1,181 @@
// 需求提报实体(M3,page-architecture)
// 需求提报(对接真实后端)
// 草稿:POST /web/workOrderDraft/{getDraft,add,upload,reset} → vm_work_order_draft(WorkOrderDraftBo / Vo)
// 提交:POST /web/requirementPool/create → vm_project_requirement(入参 WorkOrderDraftBo,与草稿同 BO,附件随请求体)
// (同控制器的 /submit 入参是 RdProjectBo、会额外落 vm_rd_project,前端当前未使用)
// 业务类型:GET /web/projectSubmission/list(vm_project_submission,@SaIgnore 免登录)
// 与首页 /web/home-page/service-type-list 是同一个 service 方法,数据完全一致
// 预算范围:GET /system/dict/data/type/vm_budget_range(字典)
// 注意:草稿 / 提交 / 字典 需登录态(匿名实测 401);业务类型列表免登录
import type { BusinessType, FileInfo } from '@/types/api'
/** 业务类型图标标识(对应前端内联 SVG,见 views/demand/cpns/BizTypeIcon.vue) */
export type BizIconKey = 'code' | 'chip' | 'iot' | 'factory' | 'other'
/** 预算范围 */
export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
/** 需求提报表单 */
export interface Demand {
businessType: BusinessType
projectName: string
attachments: FileInfo[]
coreDescription: string
expectedDuration: number | ''
budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
userName: string
userPhone: string
remark: string
status: 'draft' | 'submitted'
/** 字典项(GET /system/dict/data/type/{dictType}) */
export interface DictOption {
dictLabel: string
dictValue: string
dictSort: number
}
/** 项目提报列表项(GET /web/projectSubmission/list,只取业务类型卡片用得到的字段) */
export interface SubmissionBizTypeVo {
/** 提报主键(业务详情接口 /web/projectSubmission/{id} 用它) */
id: string | number
/** 业务类型(既作展示名,也作下发给后端的值) */
businessType: string | null
/** 标题 */
title: string | null
/** 副标题(卡片一句话描述的数据源) */
subtitle: string | null
/** 封面(⚠️ 实测存的是 OSS 文件标识,非完整地址) */
coverUrl: string | null
/** Logo(⚠️ 同上,需经 /resource/oss/listByIds 换取真实地址) */
logoUrl: string | null
}
/**
* 业务类型卡片选项。
*
* <p>值域**完全由接口决定**:提报列表返回几条就渲染几张卡,前端不追加任何兜底项
* (「其他」也不例外——接口有才显示,没有就不显示)。字段映射:`businessType` 作下发值、
* `title` 作展示名、`subtitle` 作描述、`logoUrl` 作图标。但后端这两个字段目前是**半成品**——
* subtitle 常与类型名重复、logo 存的是 OSS 文件标识——故 desc / icon 保留前端映射表兜底
* (见 `utils/bizTypeMeta.ts`,只按类型名补描述/图标,不产生新选项)。</p>
*/
export interface BizTypeOption {
/** 下发给后端的值(= 提报 businessType,如「互联网软件」) */
value: string
/** 展示名 */
label: string
/** 一句话描述(后端 subtitle 可用时优先,否则用前端映射) */
desc: string
/** 图标标识(前端内联 SVG;`iconUrl` 为空时渲染它) */
icon: BizIconKey
/** 后端 Logo 图片地址(已换链可直接访问时优先渲染,否则回退 `icon` 内联 SVG) */
iconUrl?: string
}
/** 草稿附件(vm_work_order_draft_attachment) */
export interface DemandAttachment {
id?: string | number | null
draftId?: string | number | null
/** OSS 文件主键 */
ossId?: string | number | null
/** 原始文件名 */
originalName?: string | null
/** 文件访问地址 */
url?: string | null
/** 文件大小(字节) */
fileSize?: number | null
/** 文件格式后缀(pdf / docx / zip …) */
type?: string | null
/** 上传时间(BaseEntity.createTime;本地上传时由前端补当前时间) */
createTime?: string | null
}
/** 需求提报表单(字段与后端 WorkOrderDraftBo 对应,另有 1 个纯前端状态字段:budgetManual) */
export interface Demand {
/** 草稿主键(编辑已有草稿时回填) */
id?: string | number | null
/** 业务类型(= 项目提报的 businessType,如「互联网软件」;后端落库存的也是这个名称,不是提报 id) */
bizType: string
/** 项目名称 */
projectName: string
/** 核心需求描述 */
requirementDesc: string
/**
* 预算范围:选预设档位时为字典 `vm_budget_range` 的 dictValue('0'~'3');
* 在第 5 个「手填」输入框里写字时为用户自己填的金额文本(如「80万」)。
*
* <p>⚠️ 手填内容只能放本字段:后端 `WorkOrderDraftBo.budgetCustom` 是被注释掉的、
* `vm_work_order_draft` 表也没有 `budget_custom` 列(VO 上那个 `budgetCustom` 恒为 null),
* 所以没有第二个位置可以承载手填文本。提交落 `vm_project_requirement.budget_range`,
* 该列为 **varchar(30)**,故手填文本限长见 `utils/budgetMeta.ts#MANUAL_BUDGET_MAX`。</p>
*/
budgetRange: string
/** 是否由「手填」输入框提供预算(**纯前端状态**:后端 BO/VO 与两张表都没有该字段,下发前需剔除) */
budgetManual: boolean
/** 预计开发周期(工作日) */
developmentCycle: number | ''
/** 补充备注 */
supplementaryRemarks: string
/** 用户名称 */
userName: string
/** 用户电话 */
userPhone: string
/** 附件列表(后端按此逐条转存为需求附件) */
attachmentInfo: DemandAttachment[]
}
/**
* 直接提报请求体(对应后端 `RdProjectBo` 的可写子集,POST /web/requirementPool/submit)。
*
* <p>⚠️ **当前前端未使用**:提交走的是 `/web/requirementPool/create`(入参 `WorkOrderDraftBo`,
* 与草稿同一个 BO,附件随请求体下发)。本类型对应的是后端 `/submit` 链路,保留以备切回。</p>
*
* <p>⚠️ 与草稿 BO 的字段名**不同**,别混用:业务类型叫 `businessType`(草稿叫 `bizType`)、
* 补充备注叫 `extraRemark`(草稿叫 `supplementaryRemarks`)。</p>
*
* <p>⚠️ 两个字段**不在**此请求体里:</p>
* <ul>
* <li>`developmentCycle`(预计开发周期)—— `vm_project_requirement` 与 `vm_rd_project`
* 两张表都**没有**该列,所以提报链路无处存放(草稿表 `vm_work_order_draft.development_cycle`
* 里有,提交后即丢失,草稿路径同样如此);</li>
* <li>`attachmentInfo` —— `RdProjectBo` 无附件字段,后端按登录用户从**已落库草稿**里
* 按 `ossId` 搬运到 `vm_project_requirement_attachment`(故走该链路时调用方需先落一次草稿,
* 这也正是前端改回 `/create` 的原因之一)。</li>
* </ul>
*
* <p>`projectNo` / `sourceType` / `priority` / `status` / `progress` 在 BO 上虽标了 `@NotBlank`,
* 但注解都挂 `AddGroup` / `EditGroup`,而控制器是裸 `@Validated`(未指定分组)⇒ 实际**不生效**;
* 这些值由后端统一生成/兜默认(`project_no` 复用需求编号、`source_type=EXTERNAL`、
* `priority=UNASSESSED`、`status=IN_PROGRESS`、`progress=0`),前端不下发。</p>
*/
export interface DemandSubmitBo {
/** 业务类型名称(如「互联网软件」;后端落库存的是名称,不是提报 id) */
businessType: string
/** 项目名称 */
projectName: string
/** 核心需求描述 */
requirementDesc: string
/** 联系人姓名(缺失时后端回退取登录用户资料) */
customerName: string
/** 联系人电话(同上) */
customerPhone: string
/** 预算范围:预设档位 = 字典码值;手填 = 用户输入的金额文本 */
budgetRange: string | null
/** 补充备注(对应草稿的 supplementaryRemarks) */
extraRemark: string | null
}
/** 草稿详情(后端 WorkOrderDraftVo,取前端用得到的子集) */
export interface DemandDraftVo {
id: string | number | null
bizType: string | null
projectName: string | null
requirementDesc: string | null
/** 预算范围:预设档位=字典 dictValue;手填=用户在第 5 个输入框里写的文本(前端据此反推 budgetManual) */
budgetRange: string | null
/** ⚠️ 后端 VO 声明了该字段但**恒为 null**(Bo 里被注释、表无该列)→ 不要依赖它回填手填内容 */
budgetCustom: string | null
developmentCycle: number | null
supplementaryRemarks: string | null
userName: string | null
userPhone: string | null
attachmentInfo: DemandAttachment[] | null
}
/** 附件上传返回(后端 SysOssVo) */
export interface OssUploadVo {
ossId: string | number
url: string
originalName: string
fileSuffix: string
}
/** OSS 换链返回项(GET /resource/oss/listByIds/{ids} 的子集) */
export interface OssUrlVo {
ossId: string | number
/** 可直接访问的地址(阿里云 OSS 公网 URL) */
url: string
}

View File

@@ -1,7 +1,14 @@
// 首页实体(PRD 3.1/3.2,page-architecture M1)
// 首页类型(VIEW 类型 + 后端真实 VO 类型 + 门户入口类型,统一归位于 types/business/home.d.ts)
//
// - VIEW 类型(BusinessLine / HomeStats / ServiceType / ServiceContentBlock / QuickLink):页面消费层
// - 后端 VO 类型(Project* / BannerVo / AgreementFormVo / ProjectCaseVo):vermis-maker
// com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController 与 ProjectCaseUserController 的真实返回
// - 门户入口类型(PortalEntry* / EntryTone / PortalEntryCard):IPortalEntryUserController,属首页快捷入口功能
import type { BusinessType } from '@/types/api'
/* ============================ VIEW 类型(页面消费层) ============================ */
/** 数据看板 */
export interface HomeStats {
pending: number
@@ -9,7 +16,7 @@ export interface HomeStats {
total: number
}
/** 快捷入口 */
/** 快捷入口(VIEW 层占位,实际入口由门户接口驱动) */
export interface QuickLink {
label: string
path: string
@@ -19,6 +26,19 @@ export interface QuickLink {
export interface ServiceType {
label: string
desc: string
/** 图标(后端 icon_url 原样透传:图片地址时渲染为图,否则用名称首字兜底) */
icon?: string
}
/**
* 服务内容区块(对应后端富文本目录 `vm_project_submission_catalog` 的一条:
* 一条目录 = 详情页的一个「服务内容」区块,content 为富文本 HTML,可能内嵌图片/视频)
*/
export interface ServiceContentBlock {
/** 区块标题(后端 catalog.title,可为空) */
title: string
/** 区块内容(富文本 HTML 原文,直接渲染) */
content: string
}
/** 业务线(首页业务卡片 + 业务详情页共用) */
@@ -29,6 +49,179 @@ export interface BusinessLine {
slogan: string
icon: string
serviceContent: string
/** 服务内容区块列表(一条富文本目录一项,详情页逐块渲染,保留 HTML) */
contents: ServiceContentBlock[]
serviceTypes: ServiceType[]
video: string
}
/* ============================ 后端真实 VO 类型 ============================ */
/** GET /web/home-page/project-status-count —— 项目状态统计 */
export interface ProjectStatusCount {
/** 项目数量 */
projectCount: number
/** 进行中的项目数目 */
ongoingProjectCount: number
/** 待处理数目 */
pendingProjectCount: number
}
/** 服务类型子项(service-type-list / service-type-detail 内含) */
export interface ProjectServiceTypeVo {
id: number | null
projectId: number | null
serviceName: string | null
iconUrl: string | null
sortOrder: number | null
}
/** 项目背景(富文本) */
export interface ProjectBackgroundVo {
id: number | null
projectId: number | null
content: string | null
sortOrder: number | null
}
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
export interface ProjectSubmissionVo {
id: number
businessType: string | null
title: string | null
subtitle: string | null
/** 是否启用:1 启用,0 停用 */
enabled: number | null
coverUrl: string | null
logoUrl: string | null
serviceTypes: ProjectServiceTypeVo[]
backgrounds: ProjectBackgroundVo[]
/** yyyy-MM-dd HH:mm:ss */
updateTime: string | null
}
/** GET /web/home-page/service-type-detail/{id} —— 项目提报详情(聚合 VO:主表单对象,三个子表均为一对多 List) */
export interface ProjectSubmissionInfo {
projectSubmission: ProjectSubmissionFlat | null
/** 一对多:服务类型列表 */
projectServiceType: ProjectServiceTypeInfo[] | null
/** 一对多:附件列表 */
projectSubmissionAttachment: ProjectSubmissionAttachmentInfo[] | null
/** 一对多:富文本目录列表 */
projectSubmissionCatalog: ProjectSubmissionCatalogInfo[] | null
}
/** 主表 vm_project_submission(详情接口下发) */
export interface ProjectSubmissionFlat {
id: number | null
businessType: string | null
title: string | null
subtitle: string | null
enabled: number | null
coverUrl: string | null
logoUrl: string | null
}
/** 服务类型 vm_project_service_type(详情接口各取一条) */
export interface ProjectServiceTypeInfo {
id: number | null
projectId: number | null
serviceName: string | null
iconUrl: string | null
sortOrder: number | null
}
/** 附件 vm_project_submission_attachment(详情接口各取一条) */
export interface ProjectSubmissionAttachmentInfo {
id: number | null
projectId: number | null
originalName: string | null
fileSize: string | null
url: string | null
sortOrder: number | null
}
/** 目录(富文本)vm_project_submission_catalog(详情接口各取一条) */
export interface ProjectSubmissionCatalogInfo {
id: number | null
projectId: number | null
title: string | null
content: string | null
sortOrder: number | null
}
/** GET /web/home-page/carousel-list —— 轮播图 */
export interface BannerVo {
id: number | null
imageUrl: string | null
sort: number | null
/** 是否启用(true 启用,false 停用) */
enabled: boolean | null
}
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
export interface AgreementFormVo {
id: number | null
/** 协议类型(目前只有「关于我们」一条) */
type: string | null
/** 文章内容(富文本 HTML) */
content: string | null
}
// 案例库的实体类型与 VO(ProjectCaseVo / ProjectCaseUserBo / CaseSelectionModel 等)
// 已按「一页一套」归位到 `@/types/business/cases`,本文件不再重复定义。
/* ============================ 门户入口(首页快捷入口功能) ============================ */
/**
* 后端 IPortalEntryVo(入口目录 + 用户个性化分组)
*
* <p>注意字段来源分两处:</p>
* - 登录分支(selectByUserId):name / id / icon / route / groupType / sortOrder / status,
* 其中 id 取的是 vm_portal_entry.id(入口目录主键);
* - 匿名分支(selectVoList,MyBatis-Plus 自动 SQL):多带 entryId 等目录字段,groupType 为目录默认值。
*/
export interface PortalEntryVo {
/** 入口主键 */
id: number | null
/** 入口标识 */
entryId: string | null
/** 显示名 */
name: string | null
/** 图标名 / URL */
icon: string | null
/** 跳转路由 */
route: string | null
/** 权限标识(空 = 无需权限) */
permission: string | null
/** 1 = 快捷、0 = 其他 */
groupType: string | null
/** 组内顺序 */
sortOrder: number | null
/** 1 = 启用、0 = 停用 */
status: boolean | null
}
/** 入口分组:1 = 快捷入口、0 = 其他入口 */
export type PortalEntryGroup = '1' | '0'
/** 页面消费的入口卡片(视图层类型,与后端字段解耦) */
export interface PortalEntryCard {
/** 入口 id(切换分组时回传后端) */
id: number
/** 显示名 */
label: string
/** 跳转路由(可能为空,为空时不可点击跳转) */
route: string
/** 当前分组:1 = 快捷、0 = 其他 */
groupType: PortalEntryGroup
/** 图标:emoji 字符或图片地址 */
icon: string
/** icon 是否为图片地址 */
isImage: boolean
/** 图标底色主题 */
tone: EntryTone
}
/** 图标底色主题(设计稿为五色循环) */
export type EntryTone = 'purple' | 'green' | 'blue' | 'amber' | 'red' | 'cyan'

View File

@@ -2,8 +2,108 @@
import type { BusinessType, FileInfo } from '@/types/api'
/** 项目状态 */
export type ProjectStatus = '待立项' | '进行中' | '待验收' | '已完成' | '售后中' | '已取消'
/**
* 项目状态(详情页展示口径,中文)。
*
* <p>取值 = 两张表状态码值的并集经 {@link PROJECT_STATUS_LABEL} 映射后的中文:
* 需求侧 `INITIATING`(待立项)/ `CANCELLED`(已取消),项目侧 `IN_PROGRESS` /
* `PENDING_ACCEPTANCE` / `PENDING_INTERNAL_ACCEPTANCE` / `COMPLETED` / `AFTER_SALE`。
* 「待内部验收」数量少但确实存在,故保留。</p>
*/
export type ProjectStatus =
| '待立项'
| '进行中'
| '待验收'
| '待内部验收'
| '已完成'
| '售后中'
| '已取消'
/**
* 列表行状态码值 —— 后端 `COALESCE(rd_project.status, project_requirement.status)`。
*
* <p>列表接口已改为「需求表为主 + LEFT JOIN 项目表」:已立项取项目状态,未立项回落到需求状态。
* 因此这里 = 两张表状态的<b>并集</b>:<br>
* · 项目表 `vm_rd_project.status`:IN_PROGRESS / PENDING_ACCEPTANCE /
* PENDING_INTERNAL_ACCEPTANCE / COMPLETED / AFTER_SALE / CANCELLED<br>
* · 需求表 `vm_project_requirement.status` 额外提供:<b>INITIATING(待立项)</b></p>
*
* <p>所以设计稿画的「待立项」现在是真实码值(= 需求已提报、尚未立项)。</p>
*/
export type RdProjectStatus =
| 'INITIATING'
| 'IN_PROGRESS'
| 'PENDING_ACCEPTANCE'
| 'PENDING_INTERNAL_ACCEPTANCE'
| 'COMPLETED'
| 'AFTER_SALE'
| 'CANCELLED'
/**
* 项目列表行 —— 后端 `RdProjectVo`(`GET /web/requirementPool/list` 的 `requirementPage.rows`)。
*
* <p>`id` 后端是 Long,但全局 Jackson 配置序列化成字符串(已实测),故按 string 声明。
* `progress` 是 Integer,可能为 null(库里大量为空)⇒ 展示层要兜底成「—」。</p>
*/
export interface RdProjectVo {
id: string
/** 外部项目关联的需求主键,内部项目为 null */
requirementId?: string | null
projectNo: string
/** EXTERNAL / INTERNAL */
sourceType?: string | null
/** 业务类型快照:存的是**提报表主键 id**(如 '1'),不是类型名 ⇒ 展示要经 label 映射 */
businessType?: string | null
projectName: string
requirementDesc?: string | null
customerName?: string | null
customerPhone?: string | null
submitUserId?: string | null
submitTime?: string | null
/** HIGH / MEDIUM / LOW */
priority?: string | null
status?: string | null
progress?: number | null
planStartTime?: string | null
planEndTime?: string | null
actualStartTime?: string | null
actualEndTime?: string | null
customerServiceName?: string | null
technicalOwnerName?: string | null
createTime?: string | null
updateTime?: string | null
}
/** 业务类型统计单项(后端 `ProjectRequirementCountVo`) */
export interface ProjectBusinessTypeCount {
/** 提报表主键 id(与列表筛选参数 `businessType` 同口径) */
businessTypeId: string
/** 业务类型名称,如「互联网软件」 */
businessTypeLabel: string
count: number
}
/**
* 业务数据统计(后端 `ProjectRequirementStatisticsVo`)。
*
* <p>来源是独立端点 `GET /web/requirementPool/getFilterConditions`(**全量口径**,不随筛选 / 分页变化)。</p>
*
* <p>⚠️ 列表接口 `/list` 响应里的同名字段后端仍是注释状态、实测恒为 `null`,**不要**拿它当统计源。
* 这里仍按「可能为 null」处理:拿不到时筛选栏只剩「全部」一行。</p>
*/
export interface ProjectStatistics {
total?: number | null
businessTypeCounts?: ProjectBusinessTypeCount[] | null
}
/** 列表响应(后端 `ProjectRequirementAndCountVo`:分页数据 + 统计平级) */
export interface ProjectListResult {
requirementPage?: {
total: number
rows: RdProjectVo[]
} | null
requirementStatistics?: ProjectStatistics | null
}
/** 里程碑节点 */
export interface Milestone {
@@ -48,7 +148,11 @@ export interface AfterSalesPayload {
attachments: FileInfo[]
}
/** 项目 */
/**
* 项目详情(**详情页仍是前端 mock 形态**,尚未接后端)。
*
* <p>列表已改走真实接口(见 {@link RdProjectVo}),本类型只服务详情页那几个组件。</p>
*/
export interface Project {
id: string
projectNo: string
@@ -68,12 +172,201 @@ export interface Project {
afterSales?: AfterSales
}
/** 项目列表查询条件 */
/** 业务类型筛选项 + 件数(左侧筛选栏「业务类型」列表;「全部」的 value 为 'all') */
export interface ProjectTypeCount {
/** 'all' 表示「全部」;否则是后端下发的 `businessTypeId` */
value: string
/** 展示文案(后端 `businessTypeLabel`;「全部」由前端给) */
label: string
/** 该类型下的项目件数(后端全量口径;统计缺失时为 0) */
count: number
}
/**
* 项目列表查询条件(**已是后端口径**)。
*
* <p>与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessType → businessType`(提报表 id)、
* `status → status`(码值)、`startDate/endDate 或 timeShortcut → beginTime/endTime`(两者都落在
* `create_time` 上)、`pageNum/pageSize → PageQuery`。</p>
*
* <p>用空串表示「不限」而不是 'all'——空串直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。</p>
*/
export interface ProjectQuery {
keyword: string
businessType: BusinessType | 'all'
status: ProjectStatus | 'all'
/** '' = 全部;否则为后端统计下发的 businessTypeId */
businessType: string
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
status: string
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
timeShortcut: 'all' | '3m' | '6m' | '1y'
page: number
/** 提交时间自选区间起(YYYY-MM-DD,空串 = 不限) */
startDate: string
/** 提交时间自选区间止(YYYY-MM-DD,空串 = 不限) */
endDate: string
pageNum: number
pageSize: number
}
// ─────────────────────────────────────────────────────────────────────────────
// 详情契约:后端 `ProjectRequirementUserVo`(GET /web/requirementPool/get/{id})
//
// 与列表不同,详情是「需求表为主 + 各子表按需填充」:需求未立项时 `vm_rd_project`
// 没有对应行,项目侧字段(projectStage / projectMilestones / members / billPlans /
// invoices / contracts)一律为空,需求侧字段(基本信息 / requirementAttachments /
// afterSale)照常返回。
// ─────────────────────────────────────────────────────────────────────────────
/** 开发进度(后端 `ProjectStage`,表 `vm_project_stage`) */
export interface RequirementProjectStage {
id: string
projectId: string | null
requirementId: string | null
stageName: string | null
status: string | null
startTime: string | null
planTime: string | null
finishTime: string | null
/** 开发进度百分比(列表页那个 progress 的详情侧来源) */
developmentProgress: number | null
sort: number | null
}
/** 里程碑(后端 `RdProjectMilestone`,表 `vm_rd_project_milestone`) */
export interface RequirementMilestone {
id: string
projectId: string | null
milestoneName: string | null
sortOrder: number | null
status: string | null
startTime: string | null
endTime: string | null
parallelFlag: boolean | null
}
/** 需求附件(后端 `ProjectRequirementAttachment`,表 `vm_project_requirement_attachment`) */
export interface RequirementAttachment {
id: string
requirementId: string | null
/** 附件所属阶段,实测有 `REQUIREMENT` */
phase: string | null
ossId: string | null
originalName: string | null
url: string | null
fileSize: number | null
sortOrder: number | null
/** 上传时间(继承 BaseEntity,后端会下发) */
createTime?: string | null
}
/** 项目团队(后端 `RdProjectMember`,表 `vm_rd_project_member`) */
export interface RequirementMember {
id: string
projectId: string | null
userId: string | null
userName: string | null
roleKey: string | null
roleName: string | null
isOwner: boolean | null
}
/** 付款计划(后端 `BillPlan`,表 `vm_bill_plan`) */
export interface RequirementBillPlan {
id: string
projectId: string | null
projectNo: string | null
projectName: string | null
totalAmount: number | null
periods: number | null
currentPeriod: number | null
periodRule: string | null
nextDate: string | null
status: string | null
}
/** 发票(后端 `Invoice`,表 `vm_invoice`) */
export interface RequirementInvoice {
id: string
billId: string | null
invoiceTitle: string | null
taxNo: string | null
invoiceType: string | null
amount: number | null
status: string | null
invoiceNo: string | null
/** 电子发票文件地址 */
invoiceFile: string | null
applyTime: string | null
invoiceDate: string | null
/** 申请时间(继承 BaseEntity,后端会下发) */
createTime?: string | null
}
/** 合同(后端 `Contract`,表 `vm_contract`) */
export interface RequirementContract {
id: string
contractNo: string | null
projectId: string | null
title: string | null
amount: number | null
signStatus: string | null
status: string | null
signDate: string | null
/** 合同文件地址 */
file: string | null
/** 签署时间(继承 BaseEntity,后端会下发) */
createTime?: string | null
}
/** 售后工单(后端 `ProjectRequirementAfterSale`,表 `vm_project_requirement_after_sale`) */
export interface RequirementAfterSale {
id: string
requirementId: string | null
/** 售后原因 */
reason: string | null
status: string | null
requestUserName: string | null
requestTime: string | null
processUserName: string | null
processNote: string | null
processTime: string | null
withdrawReason: string | null
}
/**
* 需求详情响应(后端 `ProjectRequirementUserVo`)。
*
* <p>注意两个「名字骗人」的字段:`attachments` / `afterSales`(复数)后端**从未赋值**,
* 真正有数据的是 `requirementAttachments` 与 `afterSale`(单数)。前端只取后者。</p>
*/
export interface RequirementDetailVo {
id: string
projectNo: string | null
submitUserId: string | null
submitUserName: string | null
submitPhone: string | null
/** 业务类型**中文名**(如「硬件电路」),非英文 code */
businessType: string | null
projectName: string | null
requirementDesc: string | null
priority: string | null
/** 状态码值:需求侧 INITIATING/CANCELLED,项目侧 IN_PROGRESS/… */
status: string | null
customerServiceName: string | null
customerServicePhone: string | null
technicalOwnerName: string | null
internalRemark: string | null
attachmentCount: number | null
createTime: string | null
updateTime: string | null
launchNote: string | null
cancelReason: string | null
/** 未立项时为 null */
projectStage: RequirementProjectStage | null
projectMilestones: RequirementMilestone[]
requirementAttachments: RequirementAttachment[]
members: RequirementMember[]
billPlans: RequirementBillPlan[]
invoices: RequirementInvoice[]
contracts: RequirementContract[]
afterSale: RequirementAfterSale | null
}

View File

@@ -10,6 +10,21 @@ export interface User {
passwordModifiedAt: string
}
/**
* 登录用户资料(GET /system/user/profile,后端 SysUserVo 的子集)。
*
* <p>字段名与后端一致:`nickName` / `phoneNumber`(不是 nickname / phone)。</p>
*/
export interface UserProfileVo {
userId: string
userName: string
nickName: string
phoneNumber: string
email: string
avatarUrl: string
deptName: string
}
/** 账号密码登录参数 */
export interface LoginByAccountParams {
account: string

View File

@@ -7,5 +7,12 @@ declare module 'vue-router' {
title?: string
/** 是否需要登录 */
requiresAuth?: boolean
/**
* 是否展示公共底栏(AppFooter)。
*
* <p>默认不展示:底栏信息量大、只在门户首页作为站点信息区出现,
* 其余页面(列表/详情/登录区)不渲染,避免干扰正文。</p>
*/
footer?: boolean
}
}

View File

@@ -1,7 +1,6 @@
// 格式化工具(无 Vue 依赖)
import type { BusinessType } from '@/types/api'
import type { ScaleRange } from '@/types/business/cases'
const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
internet: '互联网软件',
@@ -11,12 +10,23 @@ const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
other: '其他',
}
const SCALE_LABELS: Record<ScaleRange, string> = {
lt50w: '50万以下',
'50to100w': '50-100万',
'100to300w': '100-300万',
'300to500w': '300-500万',
gt500w: '500万以上',
const BUSINESS_TYPE_CODES = Object.keys(BUSINESS_TYPE_LABELS) as BusinessType[]
/**
* 后端业务类型归一化:兼容英文 code(internet)与中文名称(互联网软件),未知值归 other。
*
* <p>后端案例表 / 项目提报表的 business_type 是自由文本,历史数据里两种写法混存,
* 展示与筛选都需要先归一化到前端 BusinessType。</p>
*/
export function toBusinessType(raw: string | null | undefined): BusinessType {
const value = (raw ?? '').trim()
if (!value) return 'other'
const lower = value.toLowerCase()
return (
BUSINESS_TYPE_CODES.find((code) => code === lower)
?? BUSINESS_TYPE_CODES.find((code) => BUSINESS_TYPE_LABELS[code] === value)
?? 'other'
)
}
/** 业务类型展示名 */
@@ -24,11 +34,6 @@ export function formatBusinessType(type: BusinessType): string {
return BUSINESS_TYPE_LABELS[type]
}
/** 项目规模展示名 */
export function formatScale(scale: ScaleRange): string {
return SCALE_LABELS[scale]
}
/** 金额千分位(默认两位小数) */
export function formatMoney(value: number, decimals = 2): string {
const fixed = value.toFixed(decimals)

View File

@@ -7,6 +7,16 @@ import { storage } from '@/utils/storage'
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
const SUCCESS_CODE = 200
/**
* PC 端 clientId(sys_client 种子数据,grantType 含 password)。
*
* 后端 SecurityConfig 对每个已登录请求做多端隔离校验:
* header / query 里的 clientid 必须与登录时写入 token 的 clientid 一致,
* 否则抛 NotLoginException「客户端ID与Token不匹配」。
* 所以此值必须与登录、注册请求体的 clientId 完全同源。
*/
export const CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
/** 全项目唯一 axios 实例 */
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
@@ -18,6 +28,8 @@ service.interceptors.request.use((config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 带 token 的请求必须回传 clientid,否则后端 SecurityConfig:97-106 校验不通过
config.headers.clientid = CLIENT_ID
return config
})
@@ -26,6 +38,35 @@ service.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
/**
* 查询参数序列化(按 Spring MVC WebDataBinder 的绑定约定)。
*
* <p>axios 默认把嵌套对象序列化成 `scales[0][lowerLimit]`,而 Spring 只认点号路径
* `scales[0].lowerLimit`——实测用默认形式后端直接 HTTP 500,故需要序列化的接口
* 统一走本函数(`paramsSerializer: { serialize: serializeQuery }`)。</p>
*/
export function serializeQuery(params: Record<string, unknown>): string {
const parts: string[] = []
function append(key: string, value: unknown): void {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
value.forEach((item, index) => append(`${key}[${index}]`, item))
return
}
if (typeof value === 'object') {
for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
append(`${key}.${subKey}`, subValue)
}
return
}
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
}
for (const [key, value] of Object.entries(params)) append(key, value)
return parts.join('&')
}
/**
* 统一请求:自动解包 ApiResponse<T>,失败统一提示并 reject
* 页面/组件禁止直接 import axios,一律经 store 调用本函数

View File

@@ -1,164 +1,185 @@
<script setup lang="ts">
import { formatBusinessType } from '@/utils/format'
import { computed } from 'vue'
import type { Case } from '@/types/business/cases'
interface Props {
item: Case
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
favorite: [id: string]
share: [id: string]
}>()
/** 空值兜底(后端 customerType / industry 等可能不下发) */
function dash(value: string): string {
return value || '--'
}
/** 概览信息(设计稿:标签 + 全角冒号 + 值,单行铺开) */
const meta = computed(() => [
{ label: '客户类型', value: dash(props.item.clientType) },
{ label: '所属行业', value: dash(props.item.industry) },
{ label: '规模', value: dash(props.item.scale) },
{ label: '项目周期', value: dash(props.item.duration) },
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
])
</script>
<template>
<header class="case-header">
<div class="case-header__main">
<div class="case-header__head">
<h1 class="case-header__title">
{{ item.title }}
</h1>
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
<section class="overview">
<h3 class="overview__title">
项目概览
</h3>
<div class="overview__body">
<div class="overview__meta">
<ul class="overview__row">
<li v-for="entry in meta" :key="entry.label" class="overview__item">
<span class="overview__label">{{ entry.label }}:</span>
<span class="overview__value">{{ entry.value }}</span>
</li>
</ul>
<p class="overview__item">
<span class="overview__label">完成时间:</span>
<span class="overview__value">{{ dash(props.item.finishDate) }}</span>
</p>
</div>
<div class="case-header__actions">
<div class="overview__actions">
<button
class="case-header__btn"
:class="{ 'is-favorite': item.isFavorite }"
@click="emit('favorite', item.id)"
class="overview__pill"
:class="{ 'is-active': props.item.isFavorite }"
type="button"
@click="emit('favorite', props.item.id)"
>
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
<svg
class="overview__icon"
viewBox="0 0 24 24"
:fill="props.item.isFavorite ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l8.8 8.8 8.8-8.8a5.5 5.5 0 0 0 0-7.8z" />
</svg>
<span>{{ props.item.isFavorite ? '已收藏' : '收藏' }}</span>
</button>
<button class="case-header__btn" @click="emit('share', item.id)">
分享
<button
class="overview__pill"
type="button"
@click="emit('share', props.item.id)"
>
<svg
class="overview__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.6 13.6l6.8 3.8M15.4 6.6l-6.8 3.8" />
</svg>
<span>分享</span>
</button>
</div>
<div class="case-header__meta">
<div class="case-header__meta-item">
<span class="text-secondary">完成时间</span>
<span>{{ item.finishDate }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">服务团队</span>
<span>{{ item.teamSize }}人</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">规模</span>
<span>{{ item.scale }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">所属行业</span>
<span>{{ item.industry }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">项目周期</span>
<span>{{ item.duration }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">客户类型</span>
<span>{{ item.clientType }}</span>
</div>
</div>
</div>
<div class="case-header__video">
<!-- TODO(蓝湖): 接入真实视频源 -->
<span>视频将在预览时播放</span>
<span class="case-header__video-time">0:00 / 0:00</span>
</div>
</header>
</section>
</template>
<style lang="less" scoped>
.case-header {
.flex(row, flex-start);
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
.overview {
padding: @spacing-lg;
border-radius: @radius-base;
background: @white;
box-shadow: @shadow-base;
&__main {
flex: 1;
}
&__head {
&__title {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
&::before {
content: '';
width: 4px;
height: 16px;
border-radius: 2px;
background: @primary-color;
}
}
&__tag {
padding: 2px @spacing-sm;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__btn {
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-favorite {
border-color: @warning-color;
color: @warning-color;
}
&__body {
.flex(row, space-between, center);
gap: @spacing-lg;
}
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
// 显式 stretch:否则元信息行会被居中收缩(设计稿为左起铺满整行)
.flex(column, flex-start, stretch);
flex: 1;
gap: @spacing-sm;
min-width: 0;
}
&__meta-item {
.flex(column, flex-start);
gap: 4px;
&__row {
.flex(row, space-between, center);
gap: @spacing-base;
flex-wrap: wrap;
}
&__item {
font-size: @font-size-base;
line-height: 1.5;
}
&__label {
color: @text-color-secondary;
}
&__value {
color: @heading-color;
}
&__video {
.flex(column, center);
&__actions {
.flex(row, flex-start, center);
flex-shrink: 0;
gap: @spacing-sm;
width: 320px;
height: 200px;
border-radius: @radius-base;
background: @heading-color;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__video-time {
color: @text-color-disabled;
&__pill {
.flex(row, center, center);
gap: @spacing-xs;
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover,
&.is-active {
border-color: @primary-color;
color: @primary-color;
}
}
&__icon {
width: 14px;
height: 14px;
}
}
</style>

View File

@@ -5,53 +5,84 @@ interface Props {
item: Case
}
defineProps<Props>()
const props = defineProps<Props>()
const sections = [
{ key: 'background', title: '项目背景', field: 'background' },
{ key: 'painPoints', title: '客户痛点', field: 'painPoints' },
{ key: 'requirements', title: '需求描述', field: 'requirements' },
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
] as const
/**
* 案例正文区块(设计稿:一段一张白卡,卡片标题带蓝色竖条)。
*
* <p>区块的**数量与标题都来自后端**(vm_project_case_catalog:title + content,
* 按 sort_order 升序),前端不再写死「项目背景 / 客户痛点 / 需求描述 / 交付物」,
* 后端增删目录即自动生效。空内容已在 utils/caseVo.ts#toSections 里过滤。</p>
*/
</script>
<template>
<div class="case-rich">
<section v-for="section in sections" :key="section.key" class="case-rich__section">
<h3 class="case-rich__title">
<section
v-for="(section, index) in props.item.sections"
:key="`${index}-${section.title}`"
class="case-rich__card"
>
<h3 v-if="section.title" class="case-rich__title">
{{ section.title }}
</h3>
<p class="case-rich__text">{{ item[section.field] }}</p>
<!-- 后端下发富文本 HTML(可能内嵌 img / video),直接渲染 -->
<div class="case-rich__html" v-html="section.content" />
</section>
</div>
</template>
<style lang="less" scoped>
.case-rich {
.flex(column, flex-start);
// 同 detail.vue:显式 stretch,避免正文块被压成内容宽
.flex(column, flex-start, stretch);
gap: @spacing-base;
&__section {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
&__card {
padding: @spacing-lg;
border-radius: @radius-base;
background: @white;
box-shadow: @shadow-base;
}
&__title {
margin-bottom: @spacing-sm;
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
padding-left: @spacing-sm;
border-left: 3px solid @primary-color;
&::before {
content: '';
width: 4px;
height: 16px;
border-radius: 2px;
background: @primary-color;
flex-shrink: 0;
}
}
&__text {
&__html {
font-size: @font-size-base;
line-height: 1.8;
color: @text-color;
word-break: break-word;
:deep(p) {
margin-bottom: @spacing-sm;
// 后端富文本常把「1、…2、…」分条内容放在同一个 <p> 里用 \r\n 分行,
// 默认会被 HTML 折叠成一段,故保留换行(与旧纯文本分支的 pre-wrap 行为一致)
white-space: pre-wrap;
}
:deep(img),
:deep(video) {
display: block;
width: 100%;
height: auto;
margin-bottom: @spacing-sm;
border-radius: @radius-sm;
}
}
}
</style>

View File

@@ -1,15 +1,22 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { ElDatePicker } from 'element-plus'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
import type { FilterChip } from '@/components'
import { formatDate } from '@/utils/format'
import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } from '@/types/business/cases'
interface Props {
query: CaseQuery
counts: Partial<Record<BusinessType, number>>
total: number
/** 业务类型选项(接口下发:/categories 的统计;value=提报 id,label=类型名) */
typeOptions: CaseTypeOption[]
/** 所属行业选项(接口下发:/categories) */
industryOptions: BusinessIndustryOption[]
/** 项目规模选项(接口下发:/categories,label 已由后端拼好) */
scaleOptions: ScaleLimit[]
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
caseTotal: number
/** 待提交条件的结果数(「确定筛选(N 个结果)」) */
previewTotal: number
}
const props = defineProps<Props>()
@@ -22,282 +29,405 @@ const emit = defineEmits<{
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
const activeShortcut = ref<TimeShortcut>('all')
watch(
() => props.query.dateRange,
(value) => {
if (!value) activeShortcut.value = 'all'
},
)
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
/** 完成时间后端未下发选项,保留前端快捷区间 */
const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [
{ label: '全部', value: 'all', months: 0 },
{ label: '近3个月', value: '3m', months: 3 },
{ label: '近半年', value: '6m', months: 6 },
{ label: '近一年', value: '1y', months: 12 },
]
const industryOptions = [
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
]
/**
* 规模档位的稳定标识。
*
* <p>后端档位是「上下限」对象(label 只是展示文字),用上下限拼 key 比 label 可靠:
* 区间值唯一、且不依赖后端文案。</p>
*/
function scaleKey(item: ScaleLimit): string {
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
}
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
{ label: '50万以下', value: 'lt50w' },
{ label: '50-100万', value: '50to100w' },
{ label: '100-300万', value: '100to300w' },
{ label: '300-500万', value: '300to500w' },
{ label: '500万以上', value: 'gt500w' },
]
function scaleLabel(item: ScaleLimit): string {
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
}
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
/** 快捷区间 → 具体日期区间 */
function rangeOf(months: number): [string, string] {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - months)
return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')]
}
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
/** 当前区间命中的快捷项(用于选中态回显,避免额外维护一份状态) */
const activeShortcut = computed<TimeShortcut>(() => {
const range = props.query.dateRange
if (!range) return 'all'
const hit = timeShortcuts.find(
(item) => item.value !== 'all' && rangeOf(item.months).join() === range.join(),
)
return hit ? hit.value : 'custom'
})
const dateRange = computed<[string, string] | null>({
get: () => props.query.dateRange,
set: (value) => patch({ dateRange: value }),
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.category !== 'all') {
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
const result: FilterChip[] = []
for (const value of props.query.categories) {
const option = props.typeOptions.find((item) => item.value === value)
result.push({ key: `category:${value}`, label: option?.label ?? value })
}
for (const industry of props.query.industries) {
list.push({ key: `industry:${industry}`, label: industry })
for (const name of props.query.industries) {
result.push({ key: `industry:${name}`, label: name })
}
for (const scale of props.query.scales) {
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
result.push({ key: `scale:${scaleKey(scale)}`, label: scaleLabel(scale) })
}
if (props.query.dateRange) {
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
list.push({
const label = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
result.push({
key: 'time',
label: shortcutLabel && shortcutLabel !== '全部'
? shortcutLabel
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
label: label ?? `${props.query.dateRange[0]}-${props.query.dateRange[1]}`,
})
}
return list
return result
})
function patch(partial: Partial<CaseQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function setCategory(value: BusinessType | 'all'): void {
patch({ category: value })
function toggleIn<T>(list: T[], value: T): T[] {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
}
function toggleIndustry(industry: string): void {
const next = props.query.industries.includes(industry)
? props.query.industries.filter((item) => item !== industry)
: [...props.query.industries, industry]
patch({ industries: next })
function toggleScale(item: ScaleLimit): void {
const key = scaleKey(item)
const exists = props.query.scales.some((scale) => scaleKey(scale) === key)
patch({
scales: exists
? props.query.scales.filter((scale) => scaleKey(scale) !== key)
: [...props.query.scales, { ...item }],
})
}
function toggleScale(scale: ScaleRange): void {
const next = props.query.scales.includes(scale)
? props.query.scales.filter((item) => item !== scale)
: [...props.query.scales, scale]
patch({ scales: next })
}
function setShortcut(value: TimeShortcut): void {
activeShortcut.value = value
function setShortcut(value: Exclude<TimeShortcut, 'custom'>): void {
if (value === 'all') {
patch({ dateRange: null })
return
}
const end = new Date()
const start = new Date()
if (value === '3m') start.setMonth(start.getMonth() - 3)
if (value === '6m') start.setMonth(start.getMonth() - 6)
if (value === '1y') start.setFullYear(start.getFullYear() - 1)
patch({
dateRange: [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')],
})
}
function onDateRange(value: [Date, Date] | null): void {
activeShortcut.value = 'custom'
patch({
dateRange: value
? [formatDate(value[0], 'YYYY-MM-DD'), formatDate(value[1], 'YYYY-MM-DD')]
: null,
})
const shortcut = timeShortcuts.find((item) => item.value === value)
if (shortcut) patch({ dateRange: rangeOf(shortcut.months) })
}
function onRemoveChip(key: string): void {
if (key === 'category') {
setCategory('all')
}
else if (key === 'time') {
activeShortcut.value = 'all'
if (key === 'time') {
patch({ dateRange: null })
return
}
else if (key.startsWith('industry:')) {
const industry = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== industry) })
if (key.startsWith('category:')) {
const value = key.slice('category:'.length)
patch({ categories: props.query.categories.filter((item) => item !== value) })
return
}
else if (key.startsWith('scale:')) {
const scale = key.slice('scale:'.length) as ScaleRange
patch({ scales: props.query.scales.filter((item) => item !== scale) })
if (key.startsWith('industry:')) {
const name = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== name) })
return
}
if (key.startsWith('scale:')) {
const target = key.slice('scale:'.length)
patch({ scales: props.query.scales.filter((item) => scaleKey(item) !== target) })
}
}
function onClearChips(): void {
activeShortcut.value = 'all'
emit('update:query', {
...props.query,
category: 'all',
industries: [],
scales: [],
dateRange: null,
})
function clearChips(): void {
patch({ categories: [], industries: [], scales: [], dateRange: null })
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选 + 计数) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">业务类型</span>
<div class="cases-filter__options">
<button
v-for="option in categoryOptions"
:key="option.value"
class="cases-filter__option"
:class="{ 'is-active': query.category === option.value }"
@click="setCategory(option.value)"
>
{{ option.label }}
<span class="cases-filter__count">
{{ option.value === 'all' ? total : (counts[option.value] ?? 0) }}
</span>
<aside class="case-filter">
<div class="case-filter__head">
<h2 class="case-filter__title">
筛选条件
</h2>
<button class="case-filter__link" @click="emit('reset')">
重置
</button>
</div>
</div>
<!-- 所属行业(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">所属行业</span>
<div class="cases-filter__options">
<!-- 业务类型(接口下发;带案例数) -->
<section class="case-filter__group">
<h3 class="case-filter__group-title">
业务类型
</h3>
<ul class="case-filter__rows">
<li>
<button
class="cases-filter__option"
:class="{ 'is-active': query.industries.length === 0 }"
class="case-filter__row"
:class="{ 'is-active': query.categories.length === 0 }"
@click="patch({ categories: [] })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">全部</span>
<span class="case-filter__row-count">{{ caseTotal }}</span>
</button>
</li>
<li v-for="option in typeOptions" :key="option.value">
<button
class="case-filter__row"
:class="{ 'is-active': query.categories.includes(option.value) }"
@click="patch({ categories: toggleIn(query.categories, option.value) })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">{{ option.label }}</span>
<span class="case-filter__row-count">{{ option.count }}</span>
</button>
</li>
</ul>
</section>
<!-- 所属行业(接口下发,多选) -->
<section class="case-filter__group">
<h3 class="case-filter__group-title">
所属行业
</h3>
<div class="case-filter__pills">
<button
class="case-filter__pill"
:class="{ 'is-light': query.industries.length === 0 }"
@click="patch({ industries: [] })"
>
全部
</button>
<button
v-for="industry in industryOptions"
:key="industry"
class="cases-filter__option"
:class="{ 'is-active': query.industries.includes(industry) }"
@click="toggleIndustry(industry)"
v-for="option in industryOptions"
:key="option.name ?? ''"
class="case-filter__pill"
:class="{ 'is-active': query.industries.includes(option.name ?? '') }"
@click="patch({ industries: toggleIn(query.industries, option.name ?? '') })"
>
{{ industry }}
{{ option.name }}
</button>
</div>
</div>
</section>
<!-- 项目规模(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">项目规模</span>
<div class="cases-filter__options">
<!-- 项目规模(接口下发,多选) -->
<section class="case-filter__group">
<h3 class="case-filter__group-title">
项目规模
</h3>
<div class="case-filter__pills">
<button
class="cases-filter__option"
:class="{ 'is-active': query.scales.length === 0 }"
class="case-filter__pill"
:class="{ 'is-light': query.scales.length === 0 }"
@click="patch({ scales: [] })"
>
全部
</button>
<button
v-for="scale in scaleOptions"
:key="scale.value"
class="cases-filter__option"
:class="{ 'is-active': query.scales.includes(scale.value) }"
@click="toggleScale(scale.value)"
v-for="option in scaleOptions"
:key="scaleKey(option)"
class="case-filter__pill"
:class="{ 'is-active': query.scales.some((item) => scaleKey(item) === scaleKey(option)) }"
@click="toggleScale(option)"
>
{{ scale.label }}
{{ scaleLabel(option) }}
</button>
</div>
</div>
</section>
<!-- 完成时间(快捷区间 + 自定义) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">完成时间</span>
<div class="cases-filter__options">
<!-- 完成时间(快捷区间 + 自定义;后端未下发该组选项) -->
<section class="case-filter__group">
<h3 class="case-filter__group-title">
完成时间
</h3>
<div class="case-filter__pills">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="cases-filter__option"
:class="{ 'is-active': activeShortcut === shortcut.value }"
class="case-filter__pill"
:class="{
'is-light': shortcut.value === 'all' && activeShortcut === 'all',
'is-active': shortcut.value !== 'all' && activeShortcut === shortcut.value,
}"
@click="setShortcut(shortcut.value)"
>
{{ shortcut.label }}
</button>
<ElDatePicker
v-model="query.dateRange"
v-model="dateRange"
class="case-filter__date"
type="daterange"
range-separator="—"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
class="cases-filter__date"
@change="onDateRange"
/> </div>
/>
</div>
</FilterPanel>
</section>
<!-- 已选条件 -->
<section class="case-filter__selected">
<div class="case-filter__selected-head">
<span class="case-filter__selected-title">已选条件({{ chips.length }}个)</span>
<button class="case-filter__link" @click="clearChips">
清空
</button>
</div>
<div v-if="chips.length > 0" class="case-filter__chips">
<button
v-for="chip in chips"
:key="chip.key"
class="case-filter__chip"
:title="`移除 ${chip.label}`"
@click="onRemoveChip(chip.key)"
>
{{ chip.label }} ×
</button>
</div>
<p v-else class="case-filter__selected-empty">
暂无已选条件
</p>
</section>
<div class="case-filter__actions">
<button class="case-filter__reset" @click="emit('reset')">
重置
</button>
<button class="case-filter__confirm" @click="emit('search')">
确定筛选({{ previewTotal }}个结果)
</button>
</div>
</aside>
</template>
<style lang="less" scoped>
.cases-filter {
.case-filter {
flex-shrink: 0;
width: 300px;
padding: @spacing-lg 0 @spacing-base;
border-radius: @radius-base;
background: @white;
&__head,
&__group,
&__actions {
padding: 0 @spacing-base;
}
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
&:hover {
color: @primary-hover;
}
}
&__group {
.flex(row, flex-start);
gap: @spacing-base;
margin-top: @spacing-lg;
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
/* 业务类型:整行可点,选中整行铺满底色 */
&__rows {
margin-top: @spacing-sm;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
&__row {
.flex(row, flex-start, center);
gap: @spacing-sm;
width: 100%;
height: 36px;
padding: 0 @spacing-base;
color: @text-color;
font-size: @font-size-sm;
font-size: @font-size-base;
text-align: left;
transition: background 0.2s;
&:hover {
background: @bg-color-light;
}
&.is-active {
background: @filter-selected-bg;
color: @primary-color;
font-weight: 600;
}
}
&__box {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1px solid @border-color-dark;
border-radius: 2px;
.case-filter__row.is-active & {
border-color: @primary-color;
background: @primary-color;
&::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 4px;
height: 7px;
border: solid @white;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
}
}
&__row-label {
flex: 1;
.ellipsis();
}
&__row-count {
color: @text-color-secondary;
font-weight: 400;
}
/* 胶囊选项(两列;minmax(0,1fr) 防止内部长内容(日期区间)把列撑宽) */
&__pills {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__pill {
height: 34px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
.ellipsis();
transition: all 0.2s;
&:hover {
@@ -305,20 +435,140 @@ function onClearChips(): void {
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
background: @primary-color;
color: @white;
}
&.is-light {
background: @pill-selected-bg;
color: @primary-color;
font-weight: 600;
}
}
&__count {
margin-left: 4px;
&__date {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
height: 34px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @input-bg;
box-shadow: none;
&:hover,
&.is-active,
&.is-focus {
box-shadow: none;
}
:deep(.el-range__icon),
:deep(.el-range__close-icon) {
display: none;
}
:deep(.el-range-input) {
flex: 0 1 auto;
width: 76px;
background: transparent;
color: @text-color;
font-size: @font-size-sm;
&::placeholder {
color: @text-color-secondary;
}
}
:deep(.el-range-separator) {
color: @text-color-secondary;
font-size: @font-size-sm;
line-height: 34px;
}
&.is-focus {
background: @input-bg;
}
}
/* 已选条件 */
&__selected {
margin-top: @spacing-lg;
padding: @spacing-base;
border-radius: @radius-base;
background: @chips-panel-bg;
}
&__selected-head {
.flex(row, space-between, center);
}
&__selected-title {
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
}
&__selected-empty {
margin-top: @spacing-sm;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__date {
width: 240px;
&__chips {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__chip {
height: 26px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @white;
color: @primary-color;
font-size: @font-size-sm;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
/* 底部操作 */
&__actions {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-top: @spacing-base;
}
&__reset {
width: 88px;
height: 36px;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-color;
}
}
&__confirm {
flex: 1;
height: 36px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-base;
font-weight: 600;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
}
</style>

View File

@@ -4,23 +4,34 @@ import type { Case } from '@/types/business/cases'
interface Props {
cases: Case[]
/** 当前筛选条件下的案例总数 */
total: number
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
const emit = defineEmits<{
view: [id: string]
favorite: [id: string]
}>()
</script>
<template>
<div class="cases-grid">
<template v-if="cases.length > 0">
<p class="cases-grid__total">
{{ total }}
</p>
<div v-if="cases.length > 0" class="cases-grid__list">
<CaseCard
v-for="item in cases"
:key="item.id"
:item="item"
@view="emit('view', $event)"
@favorite="emit('favorite', $event)"
/>
</template>
</div>
<p v-else class="cases-grid__empty text-secondary">
暂无符合条件的案例,请调整筛选条件
</p>
@@ -29,12 +40,20 @@ const emit = defineEmits<{ view: [id: string] }>()
<style lang="less" scoped>
.cases-grid {
&__total {
font-size: 24px;
font-weight: 600;
color: @heading-color;
}
&__list {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
margin-top: @spacing-base;
}
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;

View File

@@ -2,9 +2,9 @@
import { computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useCasesStore } from '@/stores/modules/cases'
import CaseHero from './cpns/CaseHero.vue'
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
import CaseRichText from './cpns/CaseRichText.vue'
@@ -41,22 +41,32 @@ onMounted(load)
<template>
<div class="case-detail">
<nav class="case-detail__breadcrumb text-secondary">
<div class="container case-detail__crumb">
<nav class="text-secondary">
<RouterLink to="/cases" class="link">
案例库
</RouterLink>
<span> &gt; </span>
<span>案例详情</span>
</nav>
</div>
<template v-if="item">
<!-- 封面通栏:设计稿中不被内容容器收窄 -->
<CaseHero :item="item" />
<div class="container case-detail__main">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<div class="case-detail__cta">
<AppButton type="primary" size="lg" @click="router.push('/demand')">
<!-- 设计稿:内容区整宽蓝色按钮 -->
<button
class="case-detail__cta"
type="button"
@click="router.push('/demand')"
>
一键填报类似案例
</AppButton>
</button>
</div>
</template>
@@ -68,17 +78,35 @@ onMounted(load)
<style lang="less" scoped>
.case-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
&__crumb {
padding-top: 6px;
padding-bottom: 6px;
font-size: @font-size-base;
}
// ⚠️ .flex 第三个参数是 align(默认 center),必须显式传 stretch,
// 否则各白块会被压成「内容宽 + 水平居中」(设计稿要求整宽)
&__main {
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
}
&__cta {
.flex(row, center);
padding-top: @spacing-base;
width: 100%;
height: 44px;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__empty {

View File

@@ -1,5 +1,7 @@
export { default } from './index.vue'
export { default as CasesFilter } from './cpns/CasesFilter.vue'
export { default as CasesSearchBar } from './cpns/CasesSearchBar.vue'
export { default as CasesGrid } from './cpns/CasesGrid.vue'
export { default as CaseHero } from './cpns/CaseHero.vue'
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
export { default as CaseRichText } from './cpns/CaseRichText.vue'

View File

@@ -7,16 +7,22 @@ import { PaginationBar } from '@/components'
import { useCasesStore } from '@/stores/modules/cases'
import type { CaseQuery } from '@/types/business/cases'
import CasesFilter from './cpns/CasesFilter.vue'
import CasesSearchBar from './cpns/CasesSearchBar.vue'
import CasesGrid from './cpns/CasesGrid.vue'
const router = useRouter()
const casesStore = useCasesStore()
const { query, list, total, counts } = storeToRefs(casesStore)
const { query, list, total, previewTotal, typeOptions, industryOptions, scaleOptions, caseTotal }
= storeToRefs(casesStore)
function onUpdateQuery(next: CaseQuery): void {
casesStore.setQuery(next)
}
function onUpdateKeyword(keyword: string): void {
casesStore.setQuery({ ...query.value, keyword })
}
function onSearch(): void {
void casesStore.search()
}
@@ -25,6 +31,14 @@ function onReset(): void {
void casesStore.resetQuery()
}
function onToggleFavoriteOnly(): void {
void casesStore.toggleFavoriteOnly()
}
function onFavorite(id: string): void {
void casesStore.toggleFavorite(id)
}
function onPageChange(page: number): void {
void casesStore.changePage(page)
}
@@ -34,32 +48,55 @@ function onViewDetail(id: string): void {
}
onMounted(() => {
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
// 筛选面板选项全部来自接口:业务类型 / 所属行业 / 项目规模三组都走 /categories,完成时间由前端算
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
})
</script>
<template>
<div class="cases">
<header class="cases__head">
<header class="cases__hero">
<div class="container">
<h1 class="cases__title">
成功案例库
</h1>
<p class="cases__slogan text-secondary">
<p class="cases__slogan">
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
</p>
</div>
</header>
<div class="container cases__body">
<CasesFilter
:query="query"
:counts="counts"
:total="total"
:type-options="typeOptions"
:industry-options="industryOptions"
:scale-options="scaleOptions"
:case-total="caseTotal"
:preview-total="previewTotal"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<CasesGrid :cases="list" @view="onViewDetail" />
<div class="cases__main">
<CasesSearchBar
:keyword="query.keyword"
:favorite-only="query.favoriteOnly"
@update:keyword="onUpdateKeyword"
@search="onSearch"
@reset="onReset"
@toggle-favorite-only="onToggleFavoriteOnly"
/>
<CasesGrid
:cases="list"
:total="total"
@view="onViewDetail"
@favorite="onFavorite"
/>
<div class="cases__pager">
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
@@ -67,15 +104,20 @@ onMounted(() => {
@page-change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.cases {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__hero {
padding: @spacing-lg 0;
background: @white;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
@@ -85,6 +127,24 @@ onMounted(() => {
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
color: @text-color-secondary;
}
&__body {
.flex(row, flex-start, flex-start);
gap: @spacing-base;
padding-top: @spacing-base;
}
&__main {
.flex(column, flex-start);
flex: 1;
gap: @spacing-base;
min-width: 0;
}
&__pager {
.flex(row, flex-end);
}
}
</style>

View File

@@ -1,4 +1,7 @@
export { default } from './index.vue'
export { default as DemandStep1Business } from './cpns/DemandStep1Business.vue'
export { default as DemandStep2Info } from './cpns/DemandStep2Info.vue'
export { default as DemandStep3Confirm } from './cpns/DemandStep3Confirm.vue'
export { default as DemandBizTypes } from './cpns/DemandBizTypes.vue'
export { default as DemandInfoForm } from './cpns/DemandInfoForm.vue'
export { default as DemandConfirmForm } from './cpns/DemandConfirmForm.vue'
export { default as DemandAttachments } from './cpns/DemandAttachments.vue'
export { default as DemandActionBar } from './cpns/DemandActionBar.vue'
export { default as BizTypeIcon } from './cpns/BizTypeIcon.vue'

View File

@@ -1,131 +1,143 @@
<script setup lang="ts">
// 需求提报(单页三段卡片 + 底部固定操作条,数据全部走真实接口)
// 页面 → store → api;页面不直接 import api
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { AppButton, StepForm } from '@/components'
import { useDemandStore } from '@/stores/modules/demand'
import { useUserStore } from '@/stores/modules/user'
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
import DemandStep1Business from './cpns/DemandStep1Business.vue'
import DemandStep2Info from './cpns/DemandStep2Info.vue'
import DemandStep3Confirm from './cpns/DemandStep3Confirm.vue'
import type { Demand, DemandAttachment } from '@/types/business/demand'
import DemandActionBar from './cpns/DemandActionBar.vue'
import DemandBizTypes from './cpns/DemandBizTypes.vue'
import DemandConfirmForm from './cpns/DemandConfirmForm.vue'
import DemandInfoForm from './cpns/DemandInfoForm.vue'
const STEP_TITLES = ['选择业务类型', '填写需求信息', '确认信息并补充备注']
const router = useRouter()
const demandStore = useDemandStore()
const userStore = useUserStore()
const { form, step, loading } = storeToRefs(demandStore)
const {
form,
bizTypeOptions,
budgetOptions,
loading,
saving,
submitting,
uploading,
} = storeToRefs(demandStore)
function onSelectBusiness(type: BusinessType): void {
demandStore.patchForm({ businessType: type })
function onBizType(value: string): void {
demandStore.patchForm({ bizType: value })
}
function onUpdateDemand(partial: Partial<Demand>): void {
function onUpdate(partial: Partial<Demand>): void {
demandStore.patchForm(partial)
}
function onUpload(file: File): void {
demandStore.addAttachment(file)
void demandStore.addAttachment(file)
}
function onRemoveFile(file: Demand['attachments'][number]): void {
function onRemoveFile(file: DemandAttachment): void {
demandStore.removeAttachment(file)
}
function goPrev(): void {
if (step.value > 1) demandStore.setStep(step.value - 1)
}
function goNext(): void {
if (step.value < 3) demandStore.setStep(step.value + 1)
}
async function onSubmit(): Promise<void> {
const ok = await demandStore.submit()
if (ok && userStore.isLoggedIn) {
void router.push('/project')
}
}
function onSaveDraft(): void {
void demandStore.saveDraft()
}
function onReset(): void {
demandStore.reset()
void demandStore.reset()
}
async function onSubmit(): Promise<void> {
await demandStore.submit()
}
onMounted(() => {
void demandStore.loadDraft()
void demandStore.init()
})
</script>
<template>
<div class="demand">
<div class="demand-page">
<div class="container demand" :class="{ 'is-loading': loading }">
<header class="demand__head">
<h1 class="demand__title">
需求提报
</h1>
<p class="demand__subtitle text-secondary">
<p class="demand__subtitle">
填写您的需求信息,专业顾问将快速与您联系
</p>
</header>
<StepForm :titles="STEP_TITLES" :current="step - 1">
<DemandStep1Business
v-if="step === 1"
:demand="form"
@select="onSelectBusiness"
<!-- ① 选择业务类型 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">1</span>
选择业务类型<span class="demand__required">*</span>
</h2>
<DemandBizTypes
:options="bizTypeOptions"
:model-value="form.bizType"
@update:model-value="onBizType"
/>
<DemandStep2Info
v-else-if="step === 2"
</section>
<!-- ② 填写需求信息 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">2</span>
填写需求信息<span class="demand__required">*</span>
</h2>
<DemandInfoForm
:demand="form"
@update:demand="onUpdateDemand"
:budget-options="budgetOptions"
:uploading="uploading"
@update="onUpdate"
@upload="onUpload"
@remove-file="onRemoveFile"
/>
<DemandStep3Confirm
v-else
</section>
<!-- ③ 确认信息并补充备注 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">3</span>
确认信息并补充备注
</h2>
<DemandConfirmForm
:demand="form"
@update:demand="onUpdateDemand"
@update="onUpdate"
/>
</StepForm>
<div class="demand__nav">
<AppButton :disabled="step === 1 || loading" @click="goPrev">
上一步
</AppButton>
<AppButton v-if="step < 3" type="primary" :disabled="loading" @click="goNext">
下一步
</AppButton>
</section>
</div>
<div class="demand__actions">
<AppButton type="primary" size="lg" :loading="loading" @click="onSubmit">
立即提报
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onSaveDraft">
保存草稿
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onReset">
重置
</AppButton>
</div>
<DemandActionBar
:saving="saving"
:submitting="submitting"
@save-draft="onSaveDraft"
@reset="onReset"
@submit="onSubmit"
/>
</div>
</template>
<style lang="less" scoped>
.demand {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
.demand-page {
padding-top: @spacing-lg;
// 固定底条 72px + 呼吸间距,避免遮住 ③ 卡片
padding-bottom: 104px;
&.is-loading {
.demand {
opacity: 0.6;
pointer-events: none;
}
}
}
.demand {
// 设计稿:页头下与首块间距 ≈ 20px
&__head {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
margin-bottom: @spacing-base;
}
&__title {
@@ -136,16 +148,44 @@ onMounted(() => {
&__subtitle {
font-size: @font-size-base;
color: @text-color-secondary;
}
&__nav {
.flex(row, space-between);
width: 100%;
&__card {
padding: @spacing-lg;
border-radius: @radius-base;
background: @white;
// 设计稿块间距 18px
& + & {
margin-top: 18px;
}
}
&__actions {
.flex(row, center);
gap: @spacing-base;
&__section {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
/** 区块序号:实心蓝圆 + 白色数字 */
&__index {
.flex(row, center, center);
width: 18px;
height: 18px;
border-radius: 50%;
background: @primary-color;
font-size: @font-size-sm;
font-weight: 600;
color: @white;
}
&__required {
margin-left: 2px;
color: @danger-color;
}
}
</style>

View File

@@ -16,24 +16,28 @@ defineProps<Props>()
</h3>
<span class="home-business__slogan text-secondary">研发智造一体化自营平台,全链路质量兜底</span>
</div>
<div class="home-business__grid">
<RouterLink
v-for="line in lines"
:key="line.id"
:to="`/business/${line.id}`"
class="home-business__card"
:class="`home-business__card--${line.id}`"
>
<div class="home-business__icon">
<div class="home-business__card-head">
<span class="home-business__icon">
<!-- TODO(蓝湖): 替换业务图标切图 -->
<span>{{ line.name.slice(0, 2) }}</span>
</div>
{{ line.name.slice(0, 2) }}
</span>
<h4 class="home-business__name">
{{ line.name }}
</h4>
<p class="home-business__desc text-secondary">
</div>
<p class="home-business__desc">
{{ line.desc }}
</p>
<span class="home-business__more">了解详细 ></span>
<span class="home-business__more">了解详情 ></span>
</RouterLink>
</div>
</section>
@@ -64,29 +68,31 @@ defineProps<Props>()
&__card {
.flex(column, flex-start);
gap: @spacing-sm;
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
background: @primary-light;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
}
}
.home-business__more {
color: @primary-color;
}
}
&__card-head {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__icon {
.flex(row, center);
width: 48px;
height: 48px;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: @radius-base;
background: @primary-light;
background: @white;
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
@@ -101,13 +107,68 @@ defineProps<Props>()
&__desc {
min-height: 40px;
font-size: @font-size-sm;
color: @text-color-secondary;
.multi-ellipsis(2);
}
&__more {
align-self: flex-end;
font-size: @font-size-sm;
color: @text-color-secondary;
transition: color 0.2s;
}
// 四类业务各自的主题色(与设计稿一致:卡片浅色底 + 同色系图标/文案)
&__card--internet {
background: #e8f0ff;
.home-business__icon {
color: #3370ff;
}
.home-business__desc,
.home-business__more {
color: #598bff;
}
}
&__card--hardware {
background: #e7f8ec;
.home-business__icon {
color: #22b14c;
}
.home-business__desc,
.home-business__more {
color: #34c724;
}
}
&__card--iot {
background: #f0eaff;
.home-business__icon {
color: #7c4dff;
}
.home-business__desc,
.home-business__more {
color: #9a7bff;
}
}
&__card--manufacturing {
background: #fff2e4;
.home-business__icon {
color: #ff8f1f;
}
.home-business__desc,
.home-business__more {
color: #ffa94d;
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CaseCard } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
@@ -46,16 +46,39 @@ function switchCategory(value: BusinessType | 'all'): void {
</button>
</div>
<RouterLink to="/cases" class="home-cases__more link">
查看更多 >
查看全部 >
</RouterLink>
</div>
<div class="home-cases__grid">
<CaseCard
<article
v-for="item in cases"
:key="item.id"
:item="item"
@view="$router.push(`/cases/${$event}`)"
/>
class="home-cases__card"
@click="$router.push(`/cases/${item.id}`)"
>
<div class="home-cases__cover">
<!-- TODO(蓝湖): 替换案例封面切图 -->
<img v-if="item.cover" v-lazy="item.cover" class="home-cases__img" :alt="item.title">
<div v-else class="home-cases__img home-cases__img--placeholder" />
<span class="home-cases__tag" :class="`home-cases__tag--${item.category}`">
{{ formatBusinessType(item.category) }}
</span>
</div>
<div class="home-cases__body">
<h4 class="home-cases__name">
{{ item.title }}
</h4>
<ul class="home-cases__meta">
<li>客户对象:{{ item.clientType }}</li>
<li>研发周期:{{ item.duration }}</li>
<li>团队规模:{{ item.teamSize }}人</li>
<li>完成时间:{{ item.finishDate }}</li>
</ul>
<span class="home-cases__view">查看详情</span>
</div>
</article>
</div>
</section>
</template>
@@ -64,6 +87,7 @@ function switchCategory(value: BusinessType | 'all'): void {
.home-cases {
&__head {
.flex(row, space-between);
gap: @spacing-base;
margin-bottom: @spacing-base;
}
@@ -75,6 +99,7 @@ function switchCategory(value: BusinessType | 'all'): void {
&__tabs {
.flex(row, center);
flex: 1;
gap: @spacing-sm;
}
@@ -102,5 +127,103 @@ function switchCategory(value: BusinessType | 'all'): void {
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
}
&__card {
overflow: hidden;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
.home-cases__view {
background: @primary-color;
color: @white;
}
}
}
&__cover {
position: relative;
height: 130px;
overflow: hidden;
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
&--placeholder {
background: linear-gradient(135deg, @primary-light, @bg-color);
}
}
&__tag {
position: absolute;
top: @spacing-sm;
left: @spacing-sm;
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
&--hardware {
background: #22b14c;
}
&--iot {
background: #7c4dff;
}
&--manufacturing {
background: #ff8f1f;
}
&--other {
background: @text-color-secondary;
}
}
&__body {
padding: @spacing-base;
}
&__name {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.multi-ellipsis(1);
}
&__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
li {
.ellipsis();
}
}
&__view {
display: block;
width: fit-content;
margin: @spacing-base 0 0 auto;
padding: 2px @spacing-base;
border: 1px solid @primary-color;
border-radius: @radius-sm;
color: @primary-color;
font-size: @font-size-sm;
transition: all 0.2s;
}
}
</style>

View File

@@ -1,70 +1,185 @@
<script setup lang="ts">
// 快捷入口为固定模块链接(纯 UI,静态数据)
const entries = [
{ label: '财务台账', path: '/finance/bills', icon: '💰' },
{ label: '合同台账', path: '/contract', icon: '📄' },
{ label: '客服工作台', path: '/service', icon: '💬' },
{ label: '案例库管理', path: '/cases', icon: '📁' },
{ label: '工单需求池', path: '/demand', icon: '📋' },
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useHomeStore } from '@/stores/modules/home'
import type { PortalEntryCard } from '@/types/business/home'
import { toPortalEntryCards } from '@/utils/entryIcon'
import HomeAppDialog from './HomeAppDialog.vue'
/**
* 首页「快捷入口」卡片。
*
* 数据来自 store.quickEntries(← /vermins/portalEntryUser/list 的 groupType=1 项),
* 接口不可用(游客 / 无权限)时回退到内置默认入口,保证卡片不空。
* 「更多应用」打开 HomeAppDialog,可在快捷 / 其他两组之间调整入口。
*/
const homeStore = useHomeStore()
const { quickEntries } = storeToRefs(homeStore)
const router = useRouter()
const dialogVisible = ref(false)
/** 接口不可用时的默认入口(与改造前的静态入口保持一致) */
const FALLBACK_QUICK: PortalEntryCard[] = [
{ id: 0, label: '财务台账', route: '/finance/bills', groupType: '1', icon: '💰', isImage: false, tone: 'amber' },
{ id: 0, label: '合同台账', route: '/contract', groupType: '1', icon: '📄', isImage: false, tone: 'blue' },
{ id: 0, label: '客服工作台', route: '/service', groupType: '1', icon: '💬', isImage: false, tone: 'green' },
{ id: 0, label: '案例库管理', route: '/cases', groupType: '1', icon: '📁', isImage: false, tone: 'purple' },
{ id: 0, label: '工单需求池', route: '/demand', groupType: '1', icon: '📋', isImage: false, tone: 'cyan' },
]
/** 前端已注册的静态路由(route 无效时不跳转,避免出现无匹配路由) */
const knownPaths = new Set(router.getRoutes().map((item) => item.path))
/** 卡片展示的入口(最多 5 个) */
const entries = computed<PortalEntryCard[]>(() => {
const list = toPortalEntryCards(quickEntries.value)
return (list.length > 0 ? list : FALLBACK_QUICK).slice(0, 5)
})
/** 解析可跳转路径:优先后端 route,其次同名默认入口,最后回首页 */
function resolvePath(card: PortalEntryCard): string {
if (card.route && knownPaths.has(card.route)) return card.route
const fallback = FALLBACK_QUICK.find((item) => item.label === card.label)
if (fallback && knownPaths.has(fallback.route)) return fallback.route
return '/'
}
function onMore(): void {
// 打开时同步一次,确保分组是后端最新结果
void homeStore.fetchPortalEntries()
dialogVisible.value = true
}
</script>
<template>
<section class="home-quick">
<div class="home-quick__head">
<span class="home-quick__head-icon">⊞</span>
<h3 class="home-quick__title">
快捷入口
</h3>
</div>
<div class="home-quick__list">
<RouterLink
v-for="entry in entries"
:key="entry.label"
:to="entry.path"
:key="`${entry.label}-${entry.id}`"
:to="resolvePath(entry)"
class="home-quick__item"
>
<span class="home-quick__icon">{{ entry.icon }}</span>
<span class="home-quick__icon" :class="`home-quick__icon--${entry.tone}`">
<img v-if="entry.isImage" :src="entry.icon" :alt="entry.label" class="home-quick__img">
<template v-else>{{ entry.icon }}</template>
</span>
<span class="home-quick__label">{{ entry.label }}</span>
</RouterLink>
</div>
<button class="home-quick__more link" type="button" @click="onMore">
更多应用 >
</button>
<HomeAppDialog v-model:visible="dialogVisible" />
</section>
</template>
<style lang="less" scoped>
.home-quick {
.flex(column, flex-start, stretch);
flex-shrink: 0;
width: 320px;
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__head-icon {
color: @heading-color;
font-size: @font-size-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(row, space-between);
gap: @spacing-base;
.flex(row, space-between, flex-start);
gap: @spacing-sm;
}
&__item {
.flex(column, center);
.flex(column, flex-start, center);
gap: @spacing-sm;
flex: 1;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
transition: transform 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
}
}
&__icon {
font-size: 24px;
.flex(row, center);
width: 44px;
height: 44px;
border-radius: @radius-base;
font-size: @font-size-xl;
&--amber {
background: #fff3e0;
}
&--blue {
background: @primary-light;
}
&--green {
background: #e7f8ec;
}
&--purple {
background: #f0eaff;
}
&--red {
background: #ffeceb;
}
&--cyan {
background: #e4f6fa;
}
}
&__img {
width: 24px;
height: 24px;
object-fit: contain;
}
&__label {
font-size: @font-size-base;
font-size: @font-size-sm;
color: @text-color;
text-align: center;
}
&__more {
align-self: flex-end;
padding: 0;
border: 0;
background: transparent;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
}
}
</style>

View File

@@ -7,10 +7,11 @@ interface Props {
defineProps<Props>()
/** 展示顺序与设计稿一致:累积项目 / 进行中 / 已提交 */
const items = [
{ key: 'pending', label: '待您处理' },
{ key: 'total', label: '累积项目' },
{ key: 'inProgress', label: '进行中' },
{ key: 'total', label: '累计项目' },
{ key: 'pending', label: '已提交' },
] as const
</script>
@@ -22,24 +23,23 @@ const items = [
class="home-stats__item"
>
<span class="home-stats__value">{{ stats[item.key] }}</span>
<span class="home-stats__label text-secondary">{{ item.label }}</span>
<span class="home-stats__label">{{ item.label }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.home-stats {
.flex(row, space-between);
.flex(row, flex-end);
gap: @spacing-base;
&__item {
.flex(column, center);
flex: 1;
.flex(column, center, center);
gap: @spacing-xs;
padding: @spacing-base @spacing-lg;
min-width: 84px;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
background: rgba(255, 255, 255, 0.92);
}
&__value {
@@ -49,7 +49,8 @@ const items = [
}
&__label {
font-size: @font-size-base;
font-size: @font-size-sm;
color: @text-color-secondary;
}
}
</style>

View File

@@ -18,57 +18,42 @@ const greeting = computed(() => {
</script>
<template>
<section class="home-welcome">
<div class="home-welcome__info">
<div class="home-welcome">
<h2 class="home-welcome__greeting">
{{ greeting }}
<span class="home-welcome__wave">👋</span>
</h2>
<p class="home-welcome__summary text-secondary">
<p class="home-welcome__summary">
{{ summary }}
</p>
</div>
<div class="home-welcome__brand">
<!-- TODO(蓝湖): 替换头像切图 -->
<span class="home-welcome__avatar">王</span>
<span class="home-welcome__appName">虫子智造</span>
</div>
</section>
</template>
<style lang="less" scoped>
.home-welcome {
.flex(row, space-between);
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: linear-gradient(120deg, @primary-color, @primary-hover);
color: @white;
.flex(column, center, flex-start);
gap: @spacing-sm;
&__greeting {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-xxl;
font-weight: 600;
color: @white;
}
&__summary {
margin-top: @spacing-xs;
color: rgba(255, 255, 255, 0.85);
}
&__brand {
&__wave {
.flex(row, center);
gap: @spacing-sm;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
width: 32px;
height: 32px;
border-radius: @radius-round;
background: rgba(255, 255, 255, 0.25);
font-size: @font-size-lg;
}
&__appName {
font-size: @font-size-base;
&__summary {
font-size: @font-size-lg;
color: rgba(255, 255, 255, 0.9);
}
}
</style>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 业务详情页:数据只经 useHomeStore,不直接 import api
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
import HomeServiceContents from './cpns/HomeServiceContents.vue'
import HomeServiceTypes from './cpns/HomeServiceTypes.vue'
const route = useRoute()
const router = useRouter()
const homeStore = useHomeStore()
const line = computed(() => homeStore.currentBusiness)
@@ -47,10 +50,15 @@ onMounted(load)
</div>
</header>
<HomeServiceTabs
:service-types="line.serviceTypes"
:service-content="line.serviceContent"
/>
<HomeServiceTypes :service-types="line.serviceTypes" />
<HomeServiceContents :contents="line.contents" />
<div class="business-detail__cta">
<AppButton type="primary" size="lg" @click="router.push('/demand')">
立即提报
</AppButton>
</div>
</template>
<p v-else class="text-secondary">
@@ -104,5 +112,13 @@ onMounted(load)
&__slogan {
font-size: @font-size-base;
}
&__cta {
padding-top: @spacing-base;
.app-button {
width: 100%;
}
}
}
</style>

View File

@@ -1,7 +1,11 @@
export { default } from './index.vue'
export { default as HomeBanner } from './cpns/HomeBanner.vue'
export { default as HomeWelcome } from './cpns/HomeWelcome.vue'
export { default as HomeStats } from './cpns/HomeStats.vue'
export { default as HomeQuickEntries } from './cpns/HomeQuickEntries.vue'
export { default as HomeAppDialog } from './cpns/HomeAppDialog.vue'
export { default as HomeBusinessCards } from './cpns/HomeBusinessCards.vue'
export { default as HomeCaseShowcase } from './cpns/HomeCaseShowcase.vue'
export { default as HomeServiceTabs } from './cpns/HomeServiceTabs.vue'
export { default as HomeServiceTypes } from './cpns/HomeServiceTypes.vue'
export { default as HomeServiceContents } from './cpns/HomeServiceContents.vue'

View File

@@ -4,23 +4,18 @@ import { storeToRefs } from 'pinia'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeBanner from './cpns/HomeBanner.vue'
import HomeWelcome from './cpns/HomeWelcome.vue'
import HomeStats from './cpns/HomeStats.vue'
import HomeQuickEntries from './cpns/HomeQuickEntries.vue'
import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
const homeStore = useHomeStore()
const { stats, businessLines, featuredCases } = storeToRefs(homeStore)
const { stats, banners, businessLines, featuredCases } = storeToRefs(homeStore)
const todaySummary = computed(() => `今天有${stats.value.inProgress}个项目正在进行`)
// 服务 Tab 展示互联网软件线内容(服务内容区默认锚点)
const serviceLine = computed(
() => businessLines.value.find((line) => line.id === 'internet') ?? businessLines.value[0],
)
function onCategoryChange(category: BusinessType | 'all'): void {
void homeStore.fetchFeaturedCases(category)
}
@@ -32,19 +27,28 @@ onMounted(() => {
<template>
<div class="home">
<div class="container">
<HomeBanner :banners="banners" />
</div>
<div class="container home__dashboard">
<section class="home__dashboard-main">
<HomeWelcome :summary="todaySummary" />
<HomeStats :stats="stats" />
</section>
<HomeQuickEntries />
</div>
<div class="container">
<HomeBusinessCards :lines="businessLines" />
</div>
<div class="container">
<HomeCaseShowcase
:cases="featuredCases"
@category-change="onCategoryChange"
/>
<HomeServiceTabs
v-if="serviceLine"
:service-types="serviceLine.serviceTypes"
:service-content="serviceLine.serviceContent"
/>
</div>
</div>
</template>
@@ -54,5 +58,20 @@ onMounted(() => {
gap: @spacing-xl;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__dashboard {
.flex(row, space-between, stretch);
gap: @spacing-base;
}
&__dashboard-main {
.flex(row, space-between, center);
flex: 1;
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
background: linear-gradient(120deg, @primary-color, #6a5cff);
box-shadow: @shadow-base;
}
}
</style>

View File

@@ -15,8 +15,9 @@ const userStore = useUserStore()
const activeTab = ref<LoginTab>('account')
const account = ref('')
const password = ref('')
// 演示/联调默认账号:登录页打开即自动预填,可直接点登录(生产环境请清空)
const account = ref('admin')
const password = ref('admin123')
const phone = ref('')
const code = ref('')
const agree = ref(false)

View File

@@ -2,45 +2,30 @@
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { PasswordStrength, SmsCodeInput } from '@/components'
import { PasswordStrength } from '@/components'
import { useUserStore } from '@/stores/modules/user'
import { isPhone, isValidPassword } from '@/utils/validate'
const router = useRouter()
const userStore = useUserStore()
const phone = ref('')
const code = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const agree = ref(false)
let redirectTimer: number | null = null
async function onGetCode(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await userStore.sendSmsCode(phone.value)
}
catch {
// 失败提示已由 store 统一弹出,此处仅避免未处理的 rejection
}
}
async function onRegister(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
if (!username.value.trim()) {
ElMessage.warning('请输入账号')
return
}
if (!/^\d{6}$/.test(code.value)) {
ElMessage.warning('请输入6位验证码')
if (username.value.trim().length < 2 || username.value.trim().length > 30) {
ElMessage.warning('账号长度应为2-30位')
return
}
if (!isValidPassword(password.value)) {
ElMessage.warning('密码长度应为6-20位')
if (password.value.length < 5 || password.value.length > 30) {
ElMessage.warning('密码长度应为5-30位')
return
}
if (password.value !== confirmPassword.value) {
@@ -52,11 +37,9 @@ async function onRegister(): Promise<void> {
return
}
const ok = await userStore.register({
phone: phone.value,
code: code.value,
username: username.value.trim(),
password: password.value,
confirmPassword: confirmPassword.value,
agree: agree.value,
})
if (ok) {
redirectTimer = window.setTimeout(() => {
@@ -94,20 +77,19 @@ onBeforeUnmount(() => {
<form class="auth-page__form" @submit.prevent="onRegister">
<input
v-model="phone"
v-model="username"
class="auth-page__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
autocomplete="tel"
placeholder="请输入账号(2-30位)"
maxlength="30"
autocomplete="username"
>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
<div class="auth-page__pwd-wrap">
<input
v-model="password"
class="auth-page__input"
type="password"
placeholder="设置密码(6-20位)"
placeholder="设置密码(5-30位)"
autocomplete="new-password"
>
<PasswordStrength :password="password" />

View File

@@ -109,7 +109,7 @@ onMounted(() => {
</script>
<template>
<div class="after-sales">
<div class="container after-sales">
<nav class="after-sales__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
@@ -247,7 +247,7 @@ onMounted(() => {
<style lang="less" scoped>
.after-sales {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
@@ -257,12 +257,12 @@ onMounted(() => {
}
&__section {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-lg;
}
&__field {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -343,7 +343,7 @@ onMounted(() => {
}
&__files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}

View File

@@ -69,7 +69,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -77,7 +77,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
box-shadow: @shadow-base;
&__card {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
padding: @spacing-base;
border: 1px solid @border-color;

View File

@@ -64,7 +64,7 @@ const emit = defineEmits<{
<style lang="less" scoped>
.project-files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -78,7 +78,7 @@ const emit = defineEmits<{
}
&__list {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -92,7 +92,7 @@ const emit = defineEmits<{
}
&__info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
min-width: 0;
}

View File

@@ -1,13 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { ProjectQuery, ProjectStatus } from '@/types/business/project'
import { computed, ref } from 'vue'
import { PROJECT_STATUS_LABEL, PROJECT_STATUS_OPTIONS, PROJECT_TIME_OPTIONS } from '@/utils/projectMeta'
import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
/**
* 项目中心左侧筛选栏(按项目中心设计稿还原)。
*
* <p>⚠️ 业务类型的**值域、文案、件数全部来自 store**(`typeCounts` ← `GET /web/requirementPool/getFilterConditions`
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
*
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
*/
interface Props {
query: ProjectQuery
/** 当前筛选命中的项目数(确定筛选按钮上的「N个结果」) */
total: number
typeCounts: ProjectTypeCount[]
}
interface Chip {
key: string
label: string
}
const props = defineProps<Props>()
@@ -18,48 +34,31 @@ const emit = defineEmits<{
reset: []
}>()
const businessTypeOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
/** 自选时间区间是否展开(设计稿只给了按钮态,展开后是两个原生日期输入) */
const rangeOpen = ref(false)
const statusOptions: Array<{ label: string; value: ProjectStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待立项', value: '待立项' },
{ label: '进行中', value: '进行中' },
{ label: '待验收', value: '待验收' },
{ label: '已完成', value: '已完成' },
{ label: '售后中', value: '售后中' },
{ label: '已取消', value: '已取消' },
]
const timeShortcuts: Array<{ label: string; value: ProjectQuery['timeShortcut'] }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
const rangeLabel = computed(() => {
const { startDate, endDate } = props.query
if (!startDate && !endDate) return '开始时间—结束时间'
return `${startDate || '不限'} — ${endDate || '不限'}`
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.businessType !== 'all') {
list.push({ key: 'businessType', label: formatBusinessType(props.query.businessType) })
const chips = computed<Chip[]>(() => {
const list: Chip[] = []
if (props.query.businessType) {
const found = props.typeCounts.find((item) => item.value === props.query.businessType)
list.push({ key: 'businessType', label: found?.label ?? props.query.businessType })
}
if (props.query.status !== 'all') {
list.push({ key: 'status', label: props.query.status })
if (props.query.status) {
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
}
if (props.query.timeShortcut !== 'all') {
const label = timeShortcuts.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
const label = PROJECT_TIME_OPTIONS.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
list.push({ key: 'time', label })
}
if (props.query.startDate || props.query.endDate) {
list.push({ key: 'range', label: `${props.query.startDate || '不限'}~${props.query.endDate || '不限'}` })
}
return list
})
@@ -67,125 +66,386 @@ function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
rangeOpen.value = false
patch({ timeShortcut: value, startDate: '', endDate: '' })
}
/** 选了自选日期就把快捷区间复位(避免两组条件同时生效) */
function onPickDate(partial: { startDate?: string; endDate?: string }): void {
patch({ timeShortcut: 'all', ...partial })
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: 'all' })
else if (key === 'status') patch({ status: 'all' })
if (key === 'businessType') patch({ businessType: '' })
else if (key === 'status') patch({ status: '' })
else if (key === 'time') patch({ timeShortcut: 'all' })
else if (key === 'range') patch({ startDate: '', endDate: '' })
}
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: 'all',
status: 'all',
businessType: '',
status: '',
timeShortcut: 'all',
startDate: '',
endDate: '',
})
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">业务类型</span>
<div class="project-filter__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.businessType === option.value }"
@click="patch({ businessType: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<aside class="project-filter">
<h2 class="project-filter__title">
筛选条件
</h2>
<!-- 项目状态(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">项目状态</span>
<div class="project-filter__options">
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
业务类型
</h3>
<ul class="project-filter__list">
<li v-for="item in typeCounts" :key="item.value">
<button
v-for="option in statusOptions"
type="button"
class="project-filter__row"
:class="{ 'is-active': query.businessType === item.value }"
@click="patch({ businessType: item.value === 'all' ? '' : item.value })"
>
<span class="project-filter__box" aria-hidden="true" />
<span class="project-filter__row-label">{{ item.label }}</span>
<span class="project-filter__row-count">{{ item.count }}</span>
</button>
</li>
</ul>
</section>
<!-- 项目状态:2 列胶囊网格 -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
项目状态
</h3>
<div class="project-filter__grid">
<button
v-for="option in PROJECT_STATUS_OPTIONS"
:key="option.value"
class="project-filter__option"
type="button"
class="project-filter__pill"
:class="{ 'is-active': query.status === option.value }"
@click="patch({ status: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
</section>
<!-- 提交时间(快捷区间) -->
<div class="project-filter__group">
<span class="project-filter__group-title">提交时间</span>
<div class="project-filter__options">
<!-- 提交时间:快捷区间 + 自选区间 -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
提交时间
</h3>
<div class="project-filter__grid">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="project-filter__option"
:class="{ 'is-active': query.timeShortcut === shortcut.value }"
@click="patch({ timeShortcut: shortcut.value })"
v-for="option in PROJECT_TIME_OPTIONS"
:key="option.value"
type="button"
class="project-filter__pill"
:class="{ 'is-active': query.timeShortcut === option.value && !query.startDate && !query.endDate }"
@click="onPickShortcut(option.value)"
>
{{ shortcut.label }}
{{ option.label }}
</button>
<button
type="button"
class="project-filter__pill project-filter__pill--wide"
:class="{ 'is-active': rangeOpen || Boolean(query.startDate || query.endDate) }"
@click="rangeOpen = !rangeOpen"
>
{{ rangeLabel }}
</button>
</div>
<div v-if="rangeOpen" class="project-filter__range">
<input
class="project-filter__date"
type="date"
:value="query.startDate"
aria-label="开始时间"
@change="onPickDate({ startDate: ($event.target as HTMLInputElement).value })"
>
<span class="project-filter__range-sep">—</span>
<input
class="project-filter__date"
type="date"
:value="query.endDate"
aria-label="结束时间"
@change="onPickDate({ endDate: ($event.target as HTMLInputElement).value })"
>
</div>
</FilterPanel>
</section>
<!-- 已选条件 -->
<section class="project-filter__chips">
<div class="project-filter__chips-head">
<span class="project-filter__chips-title">已选条件({{ chips.length }}个)</span>
<button
type="button"
class="project-filter__chips-clear"
@click="onClearChips"
>
清空
</button>
</div>
<div v-if="chips.length > 0" class="project-filter__chips-list">
<button
v-for="chip in chips"
:key="chip.key"
type="button"
class="project-filter__chip"
@click="onRemoveChip(chip.key)"
>
{{ chip.label }}
<span class="project-filter__chip-x" aria-hidden="true">×</span>
</button>
</div>
</section>
<!-- 底部操作 -->
<div class="project-filter__actions">
<button
type="button"
class="project-filter__reset"
@click="emit('reset')"
>
重置
</button>
<button
type="button"
class="project-filter__submit"
@click="emit('search')"
>
确定筛选({{ total }}个结果)
</button>
</div>
</aside>
</template>
<style lang="less" scoped>
.project-filter {
// 左栏固定宽(设计稿实测 313 / 卡片间距 17 / 容器 1353)
flex: 0 0 313px;
padding: 20px 28px 24px;
border-radius: @radius-base;
background: @white;
&__title {
font-size: 24px;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__group {
.flex(row, flex-start);
gap: @spacing-base;
margin-top: 18px;
&:first-of-type {
margin-top: 22px;
}
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
margin-bottom: 12px;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
// ── 业务类型列表 ──
&__list {
display: flex;
flex-direction: column;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
&__row {
.flex(row, flex-start, center);
width: 100%;
height: 31px;
padding: 0 6px;
border-radius: @radius-sm;
font-size: @font-size-base;
color: @heading-color;
transition: background-color 0.2s, color 0.2s;
&:hover {
background: @bg-color;
}
&.is-active {
background: @filter-selected-bg;
color: @primary-color;
}
}
&__box {
flex-shrink: 0;
width: 14px;
height: 14px;
margin-right: 10px;
border: 1px solid @border-color-dark;
border-radius: 2px;
background: @white;
}
&__row-label {
flex: 1;
text-align: left;
.ellipsis();
}
&__row-count {
flex-shrink: 0;
margin-left: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
}
// ── 胶囊网格(项目状态 / 提交时间)──
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
&__pill {
height: 27px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
transition: background-color 0.2s, color 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
background: @primary-color;
color: @white;
}
&--wide {
grid-column: span 2;
}
}
// ── 自选日期区间(点开后才出现)──
&__range {
.flex(row, space-between, center);
margin-top: 9px;
gap: @spacing-xs;
}
&__date {
flex: 1;
min-width: 0;
height: 27px;
padding: 0 @spacing-xs;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
}
&__range-sep {
color: @text-color-secondary;
}
// ── 已选条件 ──
&__chips {
margin-top: 18px;
padding: 12px;
border-radius: @radius-base;
background: @chips-panel-bg;
}
&__chips-head {
.flex(row, space-between, center);
}
&__chips-title {
font-size: @font-size-sm;
color: @primary-color;
}
&__chips-clear {
font-size: @font-size-sm;
color: @primary-color;
&:hover {
opacity: 0.75;
}
}
&__chips-list {
.flex(row, flex-start, center);
flex-wrap: wrap;
margin-top: 8px;
gap: @spacing-sm;
}
&__chip {
.flex(row, center, center);
height: 22px;
padding: 0 10px;
border-radius: @radius-round;
background: @white;
color: @primary-color;
font-size: @font-size-sm;
&:hover {
background: @primary-light;
}
}
&__chip-x {
margin-left: 4px;
}
// ── 底部操作 ──
&__actions {
.flex(row, space-between, center);
margin-top: 16px;
gap: 10px;
}
&__reset {
flex: 0 0 32%;
height: 26px;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
&:hover {
color: @primary-color;
}
}
&__submit {
flex: 1;
height: 26px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-base;
&:hover {
background: @primary-hover;
}
}
}
</style>

View File

@@ -130,7 +130,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<style lang="less" scoped>
.project-finance {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -150,7 +150,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__plan {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
@@ -171,7 +171,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__plan-meta {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}
@@ -222,7 +222,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -237,7 +237,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__file-info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
min-width: 0;
}

View File

@@ -13,6 +13,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'待内部验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
@@ -70,7 +71,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
}
&__meta-item {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}

View File

@@ -138,7 +138,7 @@ defineProps<Props>()
}
&__body {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
}

View File

@@ -1,26 +1,58 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
import { computed } from 'vue'
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
import type { ProjectTypeCount, RdProjectVo } from '@/types/business/project'
/**
* 项目中心列表(按设计稿还原 + 后端 RdProjectVo 契约)。
*
* <p>设计稿要点:表头浅灰底 + 加粗居中、整表 #bbbbbb 网格线、行高约 76、
* 进度列**只给百分比文字**(不画进度条)、状态列是纯文字(不是彩色标签)、
* 最后一列是操作(「详情」,已完成的额外给「申请售后」)。</p>
*
* <p>三处「后端没有 / 前端不能编」的兜底:<br>
* 1. 业务类型列:SQL 里 `businessType` 已 JOIN 提报表**翻译成中文类型名**(如「互联网软件」),
* 直接渲染即可;`typeLabels`(提报 id → 名称)当前命不中,只作为后端改回下发 id 时的兜底。<br>
* 2. 进度列:`progress` 取项目表(未立项必为 null)⇒ 显示「—」,不假装成 0%。<br>
* 3. 状态列:取 `COALESCE(项目状态, 需求状态)`,未立项回落 `INITIATING`(待立项)。</p>
*/
interface Props {
projects: Project[]
projects: RdProjectVo[]
/** 业务类型件数列表(含「全部」);用它建 id → 中文 的映射 */
typeCounts: ProjectTypeCount[]
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
filtered: boolean
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
afterSales: [id: string]
}>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
/** businessTypeId → 业务类型中文 */
const typeLabels = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const item of props.typeCounts) {
if (item.value !== 'all') map[item.value] = item.label
}
return map
})
function typeLabel(value: string | null | undefined): string {
if (!value) return '—'
return typeLabels.value[value] ?? value
}
function statusLabel(value: string | null | undefined): string {
if (!value) return '—'
return PROJECT_STATUS_LABEL[value] ?? value
}
function progressLabel(value: number | null | undefined): string {
return value == null ? '—' : `${value}%`
}
</script>
@@ -35,7 +67,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<th>业务类型</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
<th />
</tr>
</thead>
<tbody>
@@ -45,37 +77,30 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<td class="project-table__name" :title="item.projectName">
{{ item.projectName }}
</td>
<td>{{ formatBusinessType(item.businessType) }}</td>
<td>
<div class="project-table__progress">
<div class="project-table__track">
<div
class="project-table__bar"
:style="{ width: `${item.progress}%` }"
/>
</div>
<span class="project-table__percent">{{ item.progress }}%</span>
</div>
</td>
<td>
<StatusTag :label="item.status" :variant="STATUS_VARIANTS[item.status]" />
</td>
<td class="project-table__actions">
<button class="project-table__btn" @click="emit('view', item.id)">
详情
</button>
<td>{{ typeLabel(item.businessType) }}</td>
<td>{{ progressLabel(item.progress) }}</td>
<td>{{ statusLabel(item.status) }}</td>
<td class="project-table__ops">
<button
v-if="item.status === '已完成'"
class="project-table__btn project-table__btn--accent"
v-if="item.status === 'COMPLETED'"
type="button"
class="project-table__link"
@click="emit('afterSales', item.id)"
>
申请售后
</button>
<button
type="button"
class="project-table__link"
@click="emit('view', item.id)"
>
详情
</button>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="7" class="project-table__empty text-secondary">
暂无符合条件的项目
<td colspan="7" class="project-table__empty">
{{ filtered ? '暂无符合条件的项目' : '暂无项目' }}
</td>
</tr>
</tbody>
@@ -86,98 +111,71 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<style lang="less" scoped>
.project-table {
overflow-x: auto;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.scrollbar();
&__grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: @font-size-base;
th,
td {
padding: @spacing-base @spacing-base;
text-align: left;
border-bottom: 1px solid @border-color;
white-space: nowrap;
border: 1px solid @project-table-line;
text-align: center;
vertical-align: middle;
// 设计稿表格正文实测 12px(比左栏正文小一号),沿用 14px 会把操作列挤到换行
font-size: @font-size-sm;
}
th {
background: @bg-color-light;
height: 44px;
background: @project-table-head-bg;
color: @heading-color;
font-weight: 600;
}
td {
color: @text-color;
height: 76px;
color: @heading-color;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: @bg-color-light;
}
// 列宽按设计稿实测比例(1002 总宽 → 61/294/218/107/144/90/88)
th:nth-child(1) { width: 6.09%; }
th:nth-child(2) { width: 29.34%; }
th:nth-child(3) { width: 21.76%; }
th:nth-child(4) { width: 10.68%; }
th:nth-child(5) { width: 14.37%; }
th:nth-child(6) { width: 8.98%; }
th:nth-child(7) { width: 8.78%; }
}
&__name {
max-width: 220px;
color: @heading-color;
padding: 0 @spacing-sm;
.ellipsis();
}
&__progress {
.flex(row, center);
gap: @spacing-sm;
min-width: 140px;
&__ops {
.flex(row, center, center);
gap: 10px;
white-space: nowrap;
}
&__track {
flex: 1;
height: 6px;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__percent {
flex-shrink: 0;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
}
&__btn {
font-size: @font-size-base;
&__link {
flex: 0 0 auto;
width: auto;
color: @primary-color;
font-size: @font-size-sm;
white-space: nowrap;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--accent {
color: @warning-color;
}
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
color: @text-color-secondary;
}
}
</style>

View File

@@ -73,7 +73,7 @@ defineProps<Props>()
}
&__info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
}

View File

@@ -36,16 +36,20 @@ function load(): void {
void projectStore.fetchDetail(projectId.value)
}
/**
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
*/
function onUpload(file: File): void {
void projectStore.uploadFile(file).then(() => {
ElMessage.success('文件已上传')
})
void file
ElMessage.info('附件上传接口待接入')
}
function onDeleteFile(index: number): void {
void projectStore.deleteFile(index).then(() => {
ElMessage.success('文件已删除')
})
void index
ElMessage.info('附件删除接口待接入')
}
function onPreviewFile(): void {
@@ -87,7 +91,7 @@ onMounted(load)
</script>
<template>
<div class="project-detail">
<div class="container project-detail">
<nav class="project-detail__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
@@ -166,7 +170,9 @@ onMounted(load)
<style lang="less" scoped>
.project-detail {
.flex(column, flex-start);
// ⚠️ mixin 签名是 .flex(@direction; @justify; @align) —— 第二参是 justify、**第三参才是 align**。
// 原来只传两参 ⇒ align 取默认的 center ⇒ 纵向容器里所有卡片被压成内容宽度、水平居中(380px 窄条)。
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;

View File

@@ -1,6 +1,8 @@
export { default } from './index.vue'
export { default as ProjectFilter } from './cpns/ProjectFilter.vue'
export { default as ProjectTable } from './cpns/ProjectTable.vue'
export { default as ProjectToolbar } from './cpns/ProjectToolbar.vue'
export { default as ProjectPager } from './cpns/ProjectPager.vue'
export { default as ProjectInfoCard } from './cpns/ProjectInfoCard.vue'
export { default as ProjectTeam } from './cpns/ProjectTeam.vue'
export { default as ProjectMilestones } from './cpns/ProjectMilestones.vue'

View File

@@ -1,22 +1,37 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useProjectStore } from '@/stores/modules/project'
import type { ProjectQuery } from '@/types/business/project'
import ProjectFilter from './cpns/ProjectFilter.vue'
import ProjectPager from './cpns/ProjectPager.vue'
import ProjectTable from './cpns/ProjectTable.vue'
import ProjectToolbar from './cpns/ProjectToolbar.vue'
const router = useRouter()
const projectStore = useProjectStore()
const { query, list, total } = storeToRefs(projectStore)
const { query, list, total, typeCounts } = storeToRefs(projectStore)
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
const filtered = computed(() => Boolean(
query.value.keyword
|| query.value.businessType
|| query.value.status
|| query.value.timeShortcut !== 'all'
|| query.value.startDate
|| query.value.endDate,
))
function onUpdateQuery(next: ProjectQuery): void {
projectStore.setQuery(next)
}
function onUpdateKeyword(keyword: string): void {
projectStore.setQuery({ ...query.value, keyword })
}
function onSearch(): void {
void projectStore.search()
}
@@ -25,8 +40,8 @@ function onReset(): void {
void projectStore.resetQuery()
}
function onPageChange(page: number): void {
void projectStore.changePage(page)
function onPageChange(pageNum: number): void {
void projectStore.changePage(pageNum)
}
function onView(id: string): void {
@@ -37,61 +52,101 @@ function onAfterSales(id: string): void {
void router.push(`/project/${id}/after-sales`)
}
// 进页面并发两件事:列表(/list)+ 业务类型筛选条件(/getFilterConditions,幂等只拉一次)
onMounted(() => {
void projectStore.fetchList()
void projectStore.ensureFilterConditions()
void projectStore.load()
})
</script>
<template>
<div class="project">
<header class="project__head">
<!-- 标题区(白底通栏,与案例库 hero 同一做法) -->
<div class="project__head">
<div class="container">
<h1 class="project__title">
项目中心
</h1>
<p class="project__slogan text-secondary">
<p class="project__slogan">
查看和管理您的所有项目,实时跟踪项目进度
</p>
</header>
</div>
</div>
<div class="container">
<div class="project__body">
<ProjectFilter
:query="query"
:total="total"
:type-counts="typeCounts"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<section class="project__main">
<ProjectToolbar
:keyword="query.keyword"
@update:keyword="onUpdateKeyword"
@search="onSearch"
@reset="onReset"
/>
<ProjectTable
:projects="list"
:type-counts="typeCounts"
:filtered="filtered"
@view="onView"
@after-sales="onAfterSales"
/>
<PaginationBar
:page="query.page"
<ProjectPager
:page="query.pageNum"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</section>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.project {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__head {
padding: 16px 0 32px;
background: @white;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__slogan {
margin-top: @spacing-xs;
margin-top: 14px;
font-size: @font-size-base;
color: @text-color-secondary;
}
// 两栏:左筛选栏固定宽、右列表自适应;align 用 stretch 让两卡等高
&__body {
.flex(row, flex-start, stretch);
gap: 17px;
padding-top: 18px;
}
&__main {
flex: 1;
min-width: 0;
padding: 18px 12px 20px;
border-radius: @radius-base;
background: @white;
}
}
</style>