恢复修改的数据
This commit is contained in:
@@ -1,14 +1,55 @@
|
|||||||
|
import { request, CLIENT_ID } from '@/utils/request'
|
||||||
|
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
|
||||||
import type {
|
import type {
|
||||||
LoginByAccountParams,
|
|
||||||
LoginBySmsParams,
|
LoginBySmsParams,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
RegisterParams,
|
|
||||||
ResetPasswordParams,
|
ResetPasswordParams,
|
||||||
User,
|
User,
|
||||||
} from '@/types/business/user'
|
} from '@/types/business/user'
|
||||||
import { isPhone, isValidPassword } from '@/utils/validate'
|
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}$/
|
const SIX_DIGIT_CODE = /^\d{6}$/
|
||||||
|
|
||||||
@@ -18,26 +59,6 @@ export function sendSmsCode(phone: string): Promise<void> {
|
|||||||
return Promise.resolve()
|
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 位数字) */
|
/** 手机验证码登录(mock:验证码须为 6 位数字) */
|
||||||
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
|
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
|
||||||
const { phone, code } = params
|
const { phone, code } = params
|
||||||
@@ -69,19 +90,6 @@ function validateAuthForm(
|
|||||||
return null
|
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> {
|
export function resetPassword(params: ResetPasswordParams): Promise<void> {
|
||||||
const message = validateAuthForm(
|
const message = validateAuthForm(
|
||||||
|
|||||||
291
src/api/cases.ts
291
src/api/cases.ts
@@ -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[] = [
|
* <p>规模区间是嵌套对象(`scales[0].lowerLimit`),必须用 serializeQuery 序列化——
|
||||||
{
|
* axios 默认的 `scales[0][lowerLimit]` 后端绑不上,实测 HTTP 500。</p>
|
||||||
id: 'case-001',
|
*
|
||||||
title: '自动化生产线升级改造项目',
|
* @param bo 筛选条件(businessTypes 按案例表 business_type 原值匹配)
|
||||||
category: 'manufacturing',
|
* @param pageNum 页码,从 1 开始(后端 PageQuery)
|
||||||
cover: '',
|
* @param pageSize 每页条数
|
||||||
finishDate: '2026/8/10',
|
*/
|
||||||
teamSize: 50,
|
export function getCaseList(
|
||||||
scale: '300万',
|
bo: ProjectCaseUserBo,
|
||||||
industry: '自动化',
|
pageNum: number,
|
||||||
duration: '150个工作日',
|
pageSize: number,
|
||||||
clientType: '企业',
|
): Promise<ProjectCaseAndCaseCountVo> {
|
||||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
return request<ProjectCaseAndCaseCountVo>({
|
||||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
url: '/web/projectCase/list',
|
||||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
method: 'get',
|
||||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
params: { ...bo, pageNum, pageSize },
|
||||||
video: '',
|
paramsSerializer: { serialize: serializeQuery },
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
* <p>注意:该接口每次调用都会把案例预览量 +1,勿在轮询 / 重试场景滥用。</p>
|
||||||
const end = new Date(range[1]).getTime()
|
*/
|
||||||
return time >= start && time <= end
|
export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
|
||||||
}
|
return request<ProjectCaseVo | null>({
|
||||||
|
url: `/web/projectCase/${id}`,
|
||||||
function applyFilters(query: CaseQuery): Case[] {
|
method: 'get',
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 案例列表(筛选 + 分页) */
|
/**
|
||||||
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> {
|
* 案例收藏 / 取消收藏。
|
||||||
const filtered = applyFilters(query)
|
*
|
||||||
const start = (query.page - 1) * query.pageSize
|
* <p>后端为 toggle 语义:已收藏则取消收藏。需登录态,匿名调用会被后端拦截。</p>
|
||||||
return Promise.resolve({
|
*/
|
||||||
list: filtered.slice(start, start + query.pageSize),
|
export function collectCase(id: string): Promise<void> {
|
||||||
total: filtered.length,
|
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('案例不存在'))
|
* <p>不传 type 时返回「各业务类型的浏览王」各一条;传 type 时按案例表
|
||||||
found.isFavorite = !found.isFavorite
|
* business_type 的原值精确匹配,返回该类型全部案例。</p>
|
||||||
return Promise.resolve({ ...found })
|
*/
|
||||||
}
|
export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
|
||||||
|
return request<ProjectCaseVo[]>({
|
||||||
/** 业务类型计数(筛选面板角标) */
|
url: '/web/projectCase/mostViewed',
|
||||||
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
method: 'get',
|
||||||
const counts: Partial<Record<BusinessType, number>> = {}
|
params: type ? { type } : undefined,
|
||||||
for (const item of MOCK_CASES) {
|
})
|
||||||
counts[item.category] = (counts[item.category] ?? 0) + 1
|
|
||||||
}
|
|
||||||
return Promise.resolve(counts)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }> {
|
* OSS 文件标识 → 可直接访问的地址。
|
||||||
if (!demand.businessType || !demand.projectName || !demand.coreDescription) {
|
*
|
||||||
return Promise.reject(new Error('需求信息不完整,无法提交'))
|
* <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) */
|
/** 新增/覆盖草稿(后端 insertByBo 按登录用户 upsert) */
|
||||||
export function getDraftDemand(): Promise<Demand | null> {
|
|
||||||
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存草稿到本地 */
|
|
||||||
export function saveDraft(demand: Demand): Promise<void> {
|
export function saveDraft(demand: Demand): Promise<void> {
|
||||||
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
|
return request<void>({
|
||||||
return Promise.resolve()
|
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),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
261
src/api/home.ts
261
src/api/home.ts
@@ -1,180 +1,97 @@
|
|||||||
import type { BusinessType } from '@/types/api'
|
// 首页接口(真实后端绑定)
|
||||||
import type { Case } from '@/types/business/cases'
|
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
||||||
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home'
|
// 前缀: /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 数据流不变)
|
/** 项目状态统计 */
|
||||||
|
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
|
||||||
const SERVICE_TYPES: ServiceType[] = [
|
return request<ProjectStatusCount>({
|
||||||
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' },
|
url: '/web/home-page/project-status-count',
|
||||||
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' },
|
method: 'get',
|
||||||
{ 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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务线列表(首页 4 卡片) */
|
/** 业务服务类型列表 */
|
||||||
export function getBusinessLines(): Promise<BusinessLine[]> {
|
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
|
||||||
return Promise.resolve(BUSINESS_LINES)
|
return request<ProjectSubmissionVo[]>({
|
||||||
|
url: '/web/home-page/service-type-list',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务详情(业务详情页) */
|
/** 业务类型详情(聚合 VO:projectSubmission / projectServiceType / projectSubmissionAttachment / projectSubmissionCatalog) */
|
||||||
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
|
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionInfo> {
|
||||||
const found = BUSINESS_LINES.find((line) => line.id === type)
|
return request<ProjectSubmissionInfo>({
|
||||||
return Promise.resolve(found ?? BUSINESS_LINES[0]!)
|
url: `/web/home-page/service-type-detail/${id}`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首页案例精选(category=all 时返回全部) */
|
/** 轮播图列表 */
|
||||||
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> {
|
export function getCarouselList(): Promise<BannerVo[]> {
|
||||||
const list = category === 'all'
|
return request<BannerVo[]>({
|
||||||
? FEATURED_CASES
|
url: '/web/home-page/carousel-list',
|
||||||
: FEATURED_CASES.filter((item) => item.category === category)
|
method: 'get',
|
||||||
return Promise.resolve(list)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台介绍(富文本 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 },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,12 +168,15 @@ const MOCK_FAVORITES: Case[] = [
|
|||||||
industry: '自动化',
|
industry: '自动化',
|
||||||
duration: '150个工作日',
|
duration: '150个工作日',
|
||||||
clientType: '企业',
|
clientType: '企业',
|
||||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
sections: [
|
||||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
{ title: '项目背景', content: '<p>传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。</p>' },
|
||||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
{ title: '客户痛点', content: '<p>1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。</p>' },
|
||||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
{ title: '需求描述', content: '<p>建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。</p>' },
|
||||||
|
{ title: '交付物', content: '<p>1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。</p>' },
|
||||||
|
],
|
||||||
video: '',
|
video: '',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
favoriteCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fav-2',
|
id: 'fav-2',
|
||||||
@@ -186,12 +189,15 @@ const MOCK_FAVORITES: Case[] = [
|
|||||||
industry: '工厂服务',
|
industry: '工厂服务',
|
||||||
duration: '200个工作日',
|
duration: '200个工作日',
|
||||||
clientType: '企业',
|
clientType: '企业',
|
||||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
sections: [
|
||||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
{ title: '项目背景', content: '<p>园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。</p>' },
|
||||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
{ title: '客户痛点', content: '<p>1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。</p>' },
|
||||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
{ title: '需求描述', content: '<p>构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。</p>' },
|
||||||
|
{ title: '交付物', content: '<p>1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。</p>' },
|
||||||
|
],
|
||||||
video: '',
|
video: '',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
favoriteCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fav-3',
|
id: 'fav-3',
|
||||||
@@ -204,12 +210,15 @@ const MOCK_FAVORITES: Case[] = [
|
|||||||
industry: '智能设备',
|
industry: '智能设备',
|
||||||
duration: '30个工作日',
|
duration: '30个工作日',
|
||||||
clientType: '企业',
|
clientType: '企业',
|
||||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
sections: [
|
||||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
{ title: '项目背景', content: '<p>客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。</p>' },
|
||||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
{ title: '客户痛点', content: '<p>1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。</p>' },
|
||||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
{ title: '需求描述', content: '<p>完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。</p>' },
|
||||||
|
{ title: '交付物', content: '<p>1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。</p>' },
|
||||||
|
],
|
||||||
video: '',
|
video: '',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
favoriteCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fav-4',
|
id: 'fav-4',
|
||||||
@@ -222,12 +231,15 @@ const MOCK_FAVORITES: Case[] = [
|
|||||||
industry: '园区服务',
|
industry: '园区服务',
|
||||||
duration: '200个工作日',
|
duration: '200个工作日',
|
||||||
clientType: '企业',
|
clientType: '企业',
|
||||||
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
sections: [
|
||||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
{ title: '项目背景', content: '<p>传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。</p>' },
|
||||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
{ title: '客户痛点', content: '<p>1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。</p>' },
|
||||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
{ title: '需求描述', content: '<p>打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。</p>' },
|
||||||
|
{ title: '交付物', content: '<p>1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。</p>' },
|
||||||
|
],
|
||||||
video: '',
|
video: '',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
favoriteCount: 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,124 @@
|
|||||||
import type { FileInfo, PageResult } from '@/types/api'
|
import type { FileInfo } from '@/types/api'
|
||||||
import type {
|
import type {
|
||||||
AfterSales,
|
AfterSales,
|
||||||
AfterSalesPayload,
|
AfterSalesPayload,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectListResult,
|
||||||
ProjectQuery,
|
ProjectQuery,
|
||||||
|
ProjectStatistics,
|
||||||
|
RequirementDetailVo,
|
||||||
} from '@/types/business/project'
|
} from '@/types/business/project'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
import { formatDate } from '@/utils/format'
|
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 文件信息 */
|
/** 构造 mock 文件信息 */
|
||||||
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||||
@@ -17,7 +128,7 @@ function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
|||||||
/** 详情 mock 所属项目 id */
|
/** 详情 mock 所属项目 id */
|
||||||
const DETAIL_ID = 'project-004'
|
const DETAIL_ID = 'project-004'
|
||||||
|
|
||||||
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
|
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合)——仅详情页链路在用 */
|
||||||
const MOCK_PROJECTS: Project[] = [
|
const MOCK_PROJECTS: Project[] = [
|
||||||
{
|
{
|
||||||
id: 'project-001',
|
id: 'project-001',
|
||||||
@@ -176,49 +287,15 @@ const PROJECT_DETAIL: Project = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交时间快捷区间匹配 */
|
/** 项目详情(真实接口;不存在 / 非本人需求返回 null) */
|
||||||
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;仅详情项目返回完整数据) */
|
|
||||||
export function getProjectDetail(id: string): Promise<Project | null> {
|
export function getProjectDetail(id: string): Promise<Project | null> {
|
||||||
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL })
|
return request<RequirementDetailVo | null>({
|
||||||
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null
|
url: `/web/requirementPool/get/${id}`,
|
||||||
return Promise.resolve(found ? { ...found } : null)
|
method: 'get',
|
||||||
|
}).then((vo) => (vo ? toProjectDetail(vo) : null))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传项目文件(追加进详情文件列表) */
|
/** 上传项目文件(mock:追加进详情文件列表) */
|
||||||
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
||||||
const info: FileInfo = {
|
const info: FileInfo = {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
@@ -230,7 +307,7 @@ export function uploadProjectFile(file: File): Promise<FileInfo> {
|
|||||||
return Promise.resolve({ ...info })
|
return Promise.resolve({ ...info })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除项目文件(按索引从详情文件列表移除) */
|
/** 删除项目文件(mock:按索引从详情文件列表移除) */
|
||||||
export function deleteProjectFile(index: number): Promise<void> {
|
export function deleteProjectFile(index: number): Promise<void> {
|
||||||
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
||||||
PROJECT_DETAIL.files.splice(index, 1)
|
PROJECT_DETAIL.files.splice(index, 1)
|
||||||
@@ -238,7 +315,7 @@ export function deleteProjectFile(index: number): Promise<void> {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交售后工单 */
|
/** 提交售后工单(mock) */
|
||||||
export function submitAfterSales(
|
export function submitAfterSales(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
payload: AfterSalesPayload,
|
payload: AfterSalesPayload,
|
||||||
@@ -255,7 +332,7 @@ export function submitAfterSales(
|
|||||||
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取消售后工单 */
|
/** 取消售后工单(mock) */
|
||||||
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
||||||
const result: AfterSales = {
|
const result: AfterSales = {
|
||||||
status: '无',
|
status: '无',
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ const siteStore = useSiteStore()
|
|||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ label: '首页', path: '/' },
|
{ label: '首页', path: '/' },
|
||||||
{ label: '案例中心', path: '/cases' },
|
{ label: '案例库', path: '/cases' },
|
||||||
{ label: '需求提报', path: '/demand' },
|
{ label: '需求池', path: '/demand' },
|
||||||
{ label: '下载 APP', path: '/' },
|
{ label: '下载 APP', path: '/' },
|
||||||
{ label: '帮助中心', path: '/profile/help' },
|
{ label: '消息中心', path: '/message' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const coreBusiness = [
|
const coreBusiness = [
|
||||||
@@ -40,6 +40,17 @@ const coreBusiness = [
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="app-footer__col">
|
||||||
<h4 class="app-footer__title">
|
<h4 class="app-footer__title">
|
||||||
快速导航
|
快速导航
|
||||||
@@ -54,17 +65,6 @@ const coreBusiness = [
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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">
|
<div class="app-footer__col">
|
||||||
<h4 class="app-footer__title">
|
<h4 class="app-footer__title">
|
||||||
联系我们
|
联系我们
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface NavItem {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ label: '首页', path: '/' },
|
{ label: '首页', path: '/' },
|
||||||
{ label: '案例库', path: '/cases' },
|
{ label: '案例库', path: '/cases' },
|
||||||
{ label: '需求提报', path: '/demand' },
|
{ label: '需求池', path: '/demand' },
|
||||||
{ label: '项目中心', path: '/project' },
|
{ label: '项目中心', path: '/project' },
|
||||||
{ label: '合同中心', path: '/contract' },
|
{ label: '合同中心', path: '/contract' },
|
||||||
{ label: '财务中心', path: '/finance' },
|
{ label: '财务中心', path: '/finance' },
|
||||||
@@ -45,6 +45,12 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="app-header__actions">
|
<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="消息中心">
|
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心">
|
||||||
<span class="app-header__bell-icon">🔔</span>
|
<span class="app-header__bell-icon">🔔</span>
|
||||||
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
|
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
|
||||||
@@ -108,6 +114,28 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
|||||||
gap: @spacing-base;
|
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 {
|
&__bell {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { formatBusinessType } from '@/utils/format'
|
import { formatBusinessType } from '@/utils/format'
|
||||||
import type { Case } from '@/types/business/cases'
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
@@ -6,36 +7,60 @@ interface Props {
|
|||||||
item: Case
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="case-card" @click="onView(item.id)">
|
<article class="case-card" @click="emit('view', item.id)">
|
||||||
<div class="case-card__cover">
|
<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">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<div class="case-card__body">
|
<div class="case-card__body">
|
||||||
<h3 class="case-card__title">
|
<h3 class="case-card__title">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<ul class="case-card__meta">
|
<ul class="case-card__meta">
|
||||||
<li>完成时间:{{ item.finishDate }}</li>
|
<li v-for="row in meta" :key="row.label">
|
||||||
<li>服务团队:{{ item.teamSize }}人</li>
|
<span class="case-card__label">{{ row.label }}:</span>{{ row.value }}
|
||||||
<li>规模:{{ item.scale }}</li>
|
</li>
|
||||||
<li>所属行业:{{ item.industry }}</li>
|
|
||||||
<li>项目周期:{{ item.duration }}</li>
|
|
||||||
<li>客户类型:{{ item.clientType }}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<span class="case-card__more">查看详情 ></span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,25 +68,22 @@ function onView(id: string): void {
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-card {
|
.case-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, transform 0.2s;
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: @shadow-lg;
|
box-shadow: @shadow-lg;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
.case-card__more {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cover {
|
&__cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 160px;
|
margin: @spacing-sm @spacing-sm 0;
|
||||||
|
border-radius: @radius-base;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__img {
|
&__img {
|
||||||
@@ -76,50 +98,104 @@ function onView(id: string): void {
|
|||||||
|
|
||||||
&__tag {
|
&__tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: @spacing-sm;
|
top: 10px;
|
||||||
left: @spacing-sm;
|
left: 10px;
|
||||||
padding: 2px @spacing-sm;
|
height: 24px;
|
||||||
border-radius: @radius-round;
|
padding: 0 10px;
|
||||||
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;
|
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @primary-color;
|
background: @primary-color;
|
||||||
color: @white;
|
color: @white;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
opacity: 0;
|
line-height: 24px;
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
&--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 {
|
&__body {
|
||||||
padding: @spacing-base;
|
padding: @spacing-sm @spacing-base @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin-bottom: @spacing-sm;
|
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
.multi-ellipsis(1);
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 4px @spacing-sm;
|
gap: 6px @spacing-sm;
|
||||||
|
margin-top: @spacing-sm;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-secondary;
|
line-height: 1.5;
|
||||||
|
color: @heading-color;
|
||||||
|
|
||||||
li {
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { AppFooter, AppHeader } from '@/components'
|
import { AppFooter, AppHeader } from '@/components'
|
||||||
import FloatingActions from './components/FloatingActions.vue'
|
import FloatingActions from './components/FloatingActions.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** 公共底栏只在首页展示(首页路由 meta.footer = true),其余页面不渲染 */
|
||||||
|
const showFooter = computed(() => route.meta.footer === true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main class="layout__main">
|
<main
|
||||||
|
class="layout__main"
|
||||||
|
:class="{ 'layout__main--full': !showFooter }"
|
||||||
|
>
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter v-if="showFooter" />
|
||||||
<FloatingActions />
|
<FloatingActions />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,6 +28,11 @@ import FloatingActions from './components/FloatingActions.vue'
|
|||||||
.layout {
|
.layout {
|
||||||
.layout__main {
|
.layout__main {
|
||||||
min-height: calc(100vh - @header-height - @footer-height);
|
min-height: calc(100vh - @header-height - @footer-height);
|
||||||
|
|
||||||
|
// 无底栏的页面:底栏不渲染,这里把它的占位高度还给内容区,避免页面底部空出一屏
|
||||||
|
&--full {
|
||||||
|
min-height: calc(100vh - @header-height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ const layoutRoutes: RouteRecordRaw = {
|
|||||||
component: () => import('@/layout/index.vue'),
|
component: () => import('@/layout/index.vue'),
|
||||||
children: [
|
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: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
||||||
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), 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: '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 } },
|
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
||||||
|
|||||||
@@ -1,41 +1,197 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { BusinessType, PageResult } from '@/types/api'
|
import type {
|
||||||
import type { Case, CaseQuery } from '@/types/business/cases'
|
BusinessIndustryOption,
|
||||||
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases'
|
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 {
|
function createDefaultQuery(): CaseQuery {
|
||||||
return {
|
return {
|
||||||
category: 'all',
|
categories: [],
|
||||||
industries: [],
|
industries: [],
|
||||||
scales: [],
|
scales: [],
|
||||||
dateRange: null,
|
dateRange: null,
|
||||||
|
favoriteOnly: false,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
page: 1,
|
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', () => {
|
export const useCasesStore = defineStore('cases', () => {
|
||||||
const query = ref<CaseQuery>(createDefaultQuery())
|
const query = ref<CaseQuery>(createDefaultQuery())
|
||||||
const list = ref<Case[]>([])
|
const list = ref<Case[]>([])
|
||||||
const total = ref(0)
|
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)
|
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> {
|
async function fetchCases(): Promise<void> {
|
||||||
const result: PageResult<Case> = await getCaseList(query.value)
|
const { bo, pageNum, pageSize } = buildQuery(query.value)
|
||||||
list.value = result.list
|
const result = await getCaseList(bo, pageNum, pageSize)
|
||||||
total.value = result.total
|
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 {
|
function setQuery(next: CaseQuery): void {
|
||||||
query.value = next
|
query.value = next
|
||||||
|
scheduleProbe()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确定筛选(重置页码后取数) */
|
/** 确定筛选(重置页码后取数) */
|
||||||
@@ -55,29 +211,61 @@ export const useCasesStore = defineStore('cases', () => {
|
|||||||
await fetchCases()
|
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> {
|
async function toggleFavorite(id: string): Promise<void> {
|
||||||
const updated = await toggleFavoriteApi(id)
|
await collectCase(id)
|
||||||
const index = list.value.findIndex((item) => item.id === id)
|
const index = list.value.findIndex((item) => item.id === id)
|
||||||
if (index >= 0) list.value[index] = updated
|
if (index >= 0) {
|
||||||
if (currentCase.value?.id === id) currentCase.value = updated
|
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 {
|
return {
|
||||||
query,
|
query,
|
||||||
list,
|
list,
|
||||||
total,
|
total,
|
||||||
counts,
|
previewTotal,
|
||||||
|
typeOptions,
|
||||||
|
typeNameById,
|
||||||
|
industryOptions,
|
||||||
|
scaleOptions,
|
||||||
|
caseTotal,
|
||||||
currentCase,
|
currentCase,
|
||||||
fetchCases,
|
fetchCases,
|
||||||
fetchCounts,
|
fetchCategories,
|
||||||
|
ensureCategories,
|
||||||
setQuery,
|
setQuery,
|
||||||
search,
|
search,
|
||||||
resetQuery,
|
resetQuery,
|
||||||
changePage,
|
changePage,
|
||||||
|
toggleFavoriteOnly,
|
||||||
fetchCaseDetail,
|
fetchCaseDetail,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,266 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { Demand } from '@/types/business/demand'
|
import type {
|
||||||
import type { FileInfo } from '@/types/api'
|
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 { formatDate } from '@/utils/format'
|
||||||
import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
|
|
||||||
|
|
||||||
function createDefaultForm(): Demand {
|
function createDefaultForm(): Demand {
|
||||||
return {
|
return {
|
||||||
businessType: 'internet',
|
id: null,
|
||||||
|
bizType: '',
|
||||||
projectName: '',
|
projectName: '',
|
||||||
attachments: [],
|
requirementDesc: '',
|
||||||
coreDescription: '',
|
|
||||||
expectedDuration: '',
|
|
||||||
budgetRange: '',
|
budgetRange: '',
|
||||||
userName: '王永志',
|
budgetManual: false,
|
||||||
userPhone: '1866****9618',
|
developmentCycle: '',
|
||||||
remark: '',
|
supplementaryRemarks: '',
|
||||||
status: 'draft',
|
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', () => {
|
export const useDemandStore = defineStore('demand', () => {
|
||||||
const form = ref<Demand>(createDefaultForm())
|
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 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 {
|
function patchForm(partial: Partial<Demand>): void {
|
||||||
form.value = { ...form.value, ...partial }
|
form.value = { ...form.value, ...partial }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */
|
/** 页面初始化:业务类型 + 预算字典 + 登录用户资料 + 已有草稿(并行) */
|
||||||
function addAttachment(file: File): void {
|
async function init(): Promise<void> {
|
||||||
const info: FileInfo = {
|
loading.value = true
|
||||||
name: file.name,
|
try {
|
||||||
size: file.size,
|
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
|
||||||
uploadTime: formatDate(new Date()),
|
loadBizTypeOptions(),
|
||||||
url: '',
|
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 {
|
/** 上传附件:先传 OSS,再把附件记录挂到表单(提交时后端按 ossId 转存) */
|
||||||
form.value.attachments = form.value.attachments.filter(
|
async function addAttachment(file: File): Promise<void> {
|
||||||
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime),
|
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> {
|
async function submit(): Promise<boolean> {
|
||||||
if (!form.value.businessType) {
|
if (!form.value.bizType) {
|
||||||
ElMessage.warning('请选择业务类型')
|
ElMessage.warning('请选择业务类型')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -62,15 +268,23 @@ export const useDemandStore = defineStore('demand', () => {
|
|||||||
ElMessage.warning('请填写项目名称')
|
ElMessage.warning('请填写项目名称')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!form.value.coreDescription.trim()) {
|
if (!form.value.requirementDesc.trim()) {
|
||||||
ElMessage.warning('请填写核心需求描述')
|
ElMessage.warning('请填写核心需求描述')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
loading.value = true
|
// 手写模式下金额会直接作为 budgetRange 提交,故空值必须拦住
|
||||||
|
if (form.value.budgetManual && !form.value.budgetRange.trim()) {
|
||||||
|
ElMessage.warning('请填写预算金额,或改选一个预设档位')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await submitDemand(form.value)
|
await submitDemand(form.value)
|
||||||
form.value.status = 'submitted'
|
const base = createDefaultForm()
|
||||||
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系')
|
base.userName = form.value.userName
|
||||||
|
base.userPhone = form.value.userPhone
|
||||||
|
form.value = base
|
||||||
|
ElMessage.success('提交成功,专业顾问将在一个工作日内与您联系')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -78,49 +292,24 @@ export const useDemandStore = defineStore('demand', () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
finally {
|
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 {
|
return {
|
||||||
form,
|
form,
|
||||||
step,
|
bizTypeOptions,
|
||||||
|
budgetOptions,
|
||||||
loading,
|
loading,
|
||||||
setStep,
|
saving,
|
||||||
|
submitting,
|
||||||
|
uploading,
|
||||||
|
init,
|
||||||
patchForm,
|
patchForm,
|
||||||
addAttachment,
|
addAttachment,
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
submit,
|
|
||||||
saveDraft,
|
saveDraft,
|
||||||
loadDraft,
|
|
||||||
reset,
|
reset,
|
||||||
|
submit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,46 +1,262 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessType } from '@/types/api'
|
||||||
import type { Case } from '@/types/business/cases'
|
import type { Case } from '@/types/business/cases'
|
||||||
import type { BusinessLine, HomeStats } from '@/types/business/home'
|
import type {
|
||||||
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home'
|
BannerVo,
|
||||||
|
BusinessLine,
|
||||||
|
HomeStats,
|
||||||
|
ProjectStatusCount,
|
||||||
|
ProjectSubmissionFlat,
|
||||||
|
ProjectSubmissionInfo,
|
||||||
|
ProjectSubmissionVo,
|
||||||
|
ServiceContentBlock,
|
||||||
|
ServiceType,
|
||||||
|
} from '@/types/business/home'
|
||||||
|
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
|
||||||
|
import {
|
||||||
|
getCarouselList,
|
||||||
|
getPortalEntryList,
|
||||||
|
getProjectStatusCount,
|
||||||
|
getServiceTypeDetail,
|
||||||
|
getServiceTypeList,
|
||||||
|
switchPortalEntryGroup as switchPortalEntryGroupApi,
|
||||||
|
} from '@/api/home'
|
||||||
|
import { getFeaturedCases as getFeaturedCasesApi } from '@/api/cases'
|
||||||
|
import { toCase } from '@/utils/caseVo'
|
||||||
|
import { formatBusinessType, toBusinessType } from '@/utils/format'
|
||||||
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页数据(数据层:取数 + 状态 + 后端 VO → 页面类型映射,页面只调本 store)
|
||||||
|
*
|
||||||
|
* 后端接口:
|
||||||
|
* - UserSideWebHomePageController(前缀 /web/home-page):看板、业务服务线、业务详情
|
||||||
|
* - ProjectCaseUserController 的 /web/projectCase/mostViewed:案例库精选
|
||||||
|
*
|
||||||
|
* 页面/组件消费的类型(BusinessLine / Case / HomeStats)保持不变,
|
||||||
|
* 后端 VO 与页面字段的差异(字段名、业务类型取值、富文本、数值单位)全部在本层对齐。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 业务类型归一化(toBusinessType)已抽到 `@/utils/format`,首页与案例库共用。
|
||||||
|
|
||||||
|
/** 富文本转纯文本(服务内容区以 <pre> 展示,需去掉 HTML 标签,避免渲染出标签本身) */
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端「项目提报」→ 业务线卡片 / 业务详情 */
|
||||||
|
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
|
||||||
|
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
|
||||||
|
label: item.serviceName ?? '',
|
||||||
|
desc: '',
|
||||||
|
icon: item.iconUrl ?? '',
|
||||||
|
}))
|
||||||
|
const serviceContent = (vo.backgrounds ?? [])
|
||||||
|
.map((item) => stripHtml(item.content ?? ''))
|
||||||
|
.filter((text) => text.length > 0)
|
||||||
|
.join('\n\n')
|
||||||
|
// 列表接口的「项目背景」富文本按条映射为服务内容区块(保留 HTML 原文,详情页逐块渲染)
|
||||||
|
const contents: ServiceContentBlock[] = (vo.backgrounds ?? [])
|
||||||
|
.map((item) => ({ title: '', content: item.content ?? '' }))
|
||||||
|
.filter((block) => block.content.trim().length > 0)
|
||||||
|
const subtitle = vo.subtitle ?? ''
|
||||||
|
return {
|
||||||
|
id: toBusinessType(vo.businessType),
|
||||||
|
name: vo.title ?? '',
|
||||||
|
desc: subtitle,
|
||||||
|
slogan: subtitle,
|
||||||
|
icon: vo.logoUrl ?? '',
|
||||||
|
serviceContent,
|
||||||
|
contents,
|
||||||
|
serviceTypes,
|
||||||
|
video: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端「业务详情聚合 VO」→ 业务线(主表单对象;服务类型 / 附件 / 富文本目录均为一对多 List,需按数组下钻读取) */
|
||||||
|
function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
|
||||||
|
const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat)
|
||||||
|
const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({
|
||||||
|
label: item.serviceName ?? '',
|
||||||
|
desc: '',
|
||||||
|
icon: item.iconUrl ?? '',
|
||||||
|
}))
|
||||||
|
const serviceContent = (info.projectSubmissionCatalog ?? [])
|
||||||
|
.map((item) => stripHtml(item.content ?? ''))
|
||||||
|
.filter((text) => text.length > 0)
|
||||||
|
.join('\n\n')
|
||||||
|
// 一、二、三…每条富文本目录 → 一个「服务内容」区块(保留 HTML,含内嵌图片/视频)
|
||||||
|
const contents: ServiceContentBlock[] = (info.projectSubmissionCatalog ?? [])
|
||||||
|
.map((item) => ({ title: item.title ?? '', content: item.content ?? '' }))
|
||||||
|
.filter((block) => block.content.trim().length > 0)
|
||||||
|
const subtitle = ps.subtitle ?? ''
|
||||||
|
return {
|
||||||
|
id: toBusinessType(ps.businessType),
|
||||||
|
name: ps.title ?? '',
|
||||||
|
desc: subtitle,
|
||||||
|
slogan: subtitle,
|
||||||
|
icon: ps.logoUrl ?? '',
|
||||||
|
serviceContent,
|
||||||
|
contents,
|
||||||
|
serviceTypes,
|
||||||
|
video: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端案例 VO → 页面 Case 的映射已抽到 `@/utils/caseVo`(首页与案例库共用)。
|
||||||
|
|
||||||
|
/** 未登录时后端看板返回 null,这里兜底为零值 */
|
||||||
|
const EMPTY_STATUS: ProjectStatusCount = {
|
||||||
|
projectCount: 0,
|
||||||
|
ongoingProjectCount: 0,
|
||||||
|
pendingProjectCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
/** 首页数据(数据层:取数 + 状态,页面只调本 store) */
|
|
||||||
export const useHomeStore = defineStore('home', () => {
|
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 stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
|
||||||
|
const banners = ref<BannerVo[]>([])
|
||||||
const businessLines = ref<BusinessLine[]>([])
|
const businessLines = ref<BusinessLine[]>([])
|
||||||
const featuredCases = ref<Case[]>([])
|
const featuredCases = ref<Case[]>([])
|
||||||
const currentBusiness = ref<BusinessLine | null>(null)
|
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> {
|
async function fetchHomeData(): Promise<void> {
|
||||||
const [homeStats, lines, cases] = await Promise.all([
|
const [statusCount, submissionList, cases, carouselList] = await Promise.all([
|
||||||
getHomeStats(),
|
getProjectStatusCount(),
|
||||||
getBusinessLines(),
|
getServiceTypeList(),
|
||||||
getFeaturedCases(),
|
getFeaturedCasesApi(),
|
||||||
|
getCarouselList(),
|
||||||
|
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
|
||||||
|
casesStore.ensureCategories(),
|
||||||
])
|
])
|
||||||
stats.value = homeStats
|
const status = statusCount ?? EMPTY_STATUS
|
||||||
businessLines.value = lines
|
stats.value = {
|
||||||
featuredCases.value = cases
|
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> {
|
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> {
|
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 {
|
return {
|
||||||
stats,
|
stats,
|
||||||
|
banners,
|
||||||
businessLines,
|
businessLines,
|
||||||
featuredCases,
|
featuredCases,
|
||||||
currentBusiness,
|
currentBusiness,
|
||||||
|
portalEntries,
|
||||||
|
portalEntriesLoaded,
|
||||||
|
quickEntries,
|
||||||
|
otherEntries,
|
||||||
fetchHomeData,
|
fetchHomeData,
|
||||||
fetchFeaturedCases,
|
fetchFeaturedCases,
|
||||||
fetchBusinessDetail,
|
fetchBusinessDetail,
|
||||||
|
fetchPortalEntries,
|
||||||
|
switchEntryGroup,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { PageResult } from '@/types/api'
|
import type {
|
||||||
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project'
|
AfterSalesPayload,
|
||||||
|
Project,
|
||||||
|
ProjectQuery,
|
||||||
|
ProjectStatistics,
|
||||||
|
ProjectTypeCount,
|
||||||
|
RdProjectVo,
|
||||||
|
} from '@/types/business/project'
|
||||||
import {
|
import {
|
||||||
cancelAfterSales as cancelAfterSalesApi,
|
cancelAfterSales as cancelAfterSalesApi,
|
||||||
deleteProjectFile,
|
deleteProjectFile,
|
||||||
getProjectDetail,
|
getProjectDetail,
|
||||||
|
getProjectFilterConditions,
|
||||||
getProjectList,
|
getProjectList,
|
||||||
submitAfterSales as submitAfterSalesApi,
|
submitAfterSales as submitAfterSalesApi,
|
||||||
uploadProjectFile,
|
uploadProjectFile,
|
||||||
@@ -15,50 +22,125 @@ import {
|
|||||||
function createDefaultQuery(): ProjectQuery {
|
function createDefaultQuery(): ProjectQuery {
|
||||||
return {
|
return {
|
||||||
keyword: '',
|
keyword: '',
|
||||||
businessType: 'all',
|
businessType: '',
|
||||||
status: 'all',
|
status: '',
|
||||||
timeShortcut: 'all',
|
timeShortcut: 'all',
|
||||||
page: 1,
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
pageNum: 1,
|
||||||
pageSize: 8,
|
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', () => {
|
export const useProjectStore = defineStore('project', () => {
|
||||||
const query = ref<ProjectQuery>(createDefaultQuery())
|
const query = ref<ProjectQuery>(createDefaultQuery())
|
||||||
const list = ref<Project[]>([])
|
/** 列表行(后端 RdProjectVo;**不是**详情那套 mock 的 Project) */
|
||||||
|
const list = ref<RdProjectVo[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const current = ref<Project | null>(null)
|
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> {
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
current.value = await getProjectDetail(id)
|
try {
|
||||||
|
current.value = await getProjectDetail(id)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
current.value = null
|
||||||
|
console.warn('[project] 项目详情加载失败', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setQuery(next: ProjectQuery): void {
|
function setQuery(next: ProjectQuery): void {
|
||||||
query.value = next
|
query.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确定筛选 / 换页后的取数统一入口(失败只提示,不清空已有数据) */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('[project] 项目列表加载失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 确定筛选(重置页码后取数) */
|
/** 确定筛选(重置页码后取数) */
|
||||||
async function search(): Promise<void> {
|
async function search(): Promise<void> {
|
||||||
query.value = { ...query.value, page: 1 }
|
query.value = { ...query.value, pageNum: 1 }
|
||||||
await fetchList()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置全部条件 */
|
/** 重置全部条件 */
|
||||||
async function resetQuery(): Promise<void> {
|
async function resetQuery(): Promise<void> {
|
||||||
query.value = createDefaultQuery()
|
query.value = createDefaultQuery()
|
||||||
await fetchList()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changePage(page: number): Promise<void> {
|
async function changePage(pageNum: number): Promise<void> {
|
||||||
query.value = { ...query.value, page }
|
query.value = { ...query.value, pageNum }
|
||||||
await fetchList()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传项目文件(追加到当前详情) */
|
/** 上传项目文件(追加到当前详情) */
|
||||||
@@ -92,9 +174,12 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
list,
|
list,
|
||||||
total,
|
total,
|
||||||
current,
|
current,
|
||||||
|
typeCounts,
|
||||||
|
ensureFilterConditions,
|
||||||
fetchList,
|
fetchList,
|
||||||
fetchDetail,
|
fetchDetail,
|
||||||
setQuery,
|
setQuery,
|
||||||
|
load,
|
||||||
search,
|
search,
|
||||||
resetQuery,
|
resetQuery,
|
||||||
changePage,
|
changePage,
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import { ElMessage } from 'element-plus'
|
|||||||
import type {
|
import type {
|
||||||
LoginByAccountParams,
|
LoginByAccountParams,
|
||||||
LoginBySmsParams,
|
LoginBySmsParams,
|
||||||
LoginResult,
|
|
||||||
RegisterParams,
|
|
||||||
ResetPasswordParams,
|
ResetPasswordParams,
|
||||||
User,
|
User,
|
||||||
} from '@/types/business/user'
|
} from '@/types/business/user'
|
||||||
import {
|
import {
|
||||||
loginByAccount as loginByAccountApi,
|
|
||||||
loginBySms as loginBySmsApi,
|
loginBySms as loginBySmsApi,
|
||||||
register as registerApi,
|
|
||||||
resetPassword as resetPasswordApi,
|
resetPassword as resetPasswordApi,
|
||||||
sendSmsCode as sendSmsCodeApi,
|
sendSmsCode as sendSmsCodeApi,
|
||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
|
import {
|
||||||
|
loginByAccountApi,
|
||||||
|
registerByAccountApi,
|
||||||
|
} from '@/api/auth'
|
||||||
import { storage } from '@/utils/storage'
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
||||||
@@ -26,7 +26,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
const isLoggedIn = computed(() => Boolean(token.value))
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
|
|
||||||
/** 发送短信验证码 */
|
/** 发送短信验证码(保留原 mock 逻辑) */
|
||||||
async function sendSmsCode(phone: string): Promise<void> {
|
async function sendSmsCode(phone: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await sendSmsCodeApi(phone)
|
await sendSmsCodeApi(phone)
|
||||||
@@ -39,18 +39,18 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 登录成功统一落库(token 持久化) */
|
/** 登录成功统一落库(token 持久化) */
|
||||||
function applyLogin(result: LoginResult): void {
|
function applyLogin(payload: { token: string; user: User | null }): void {
|
||||||
token.value = result.token
|
token.value = payload.token
|
||||||
user.value = result.user
|
user.value = payload.user
|
||||||
storage.set('token', result.token)
|
storage.set('token', payload.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 账号密码登录 */
|
/** 账号密码登录(对接真实后端 /auth/login,返回 access_token) */
|
||||||
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
|
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await loginByAccountApi(params)
|
const result = await loginByAccountApi({ username: params.account, password: params.password })
|
||||||
applyLogin(result)
|
applyLogin({ token: result.access_token, user: null })
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -63,12 +63,12 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机验证码登录 */
|
/** 手机验证码登录(保留原 mock 逻辑) */
|
||||||
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await loginBySmsApi(params)
|
const result = await loginBySmsApi(params)
|
||||||
applyLogin(result)
|
applyLogin({ token: result.token, user: result.user })
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -81,11 +81,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 */
|
/** 注册(对接真实后端 /auth/register,账号+密码,无验证码) */
|
||||||
async function register(params: RegisterParams): Promise<boolean> {
|
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await registerApi(params)
|
await registerByAccountApi({ username: params.username, password: params.password })
|
||||||
ElMessage.success('注册成功,请登录')
|
ElMessage.success('注册成功,请登录')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置密码 */
|
/** 重置密码(保留原 mock 逻辑) */
|
||||||
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,3 +35,26 @@
|
|||||||
background: transparent;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
|
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
|
||||||
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
|
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
|
||||||
|
|
||||||
// 品牌色
|
// 品牌色(按案例库设计稿标注校准)
|
||||||
@primary-color: #3370ff;
|
@primary-color: #276fff;
|
||||||
@primary-hover: #598bff;
|
@primary-hover: #598bff;
|
||||||
@primary-light: #eaf1ff;
|
@primary-light: #eaf1ff;
|
||||||
|
|
||||||
@@ -46,11 +46,31 @@
|
|||||||
@font-size-xl: 20px;
|
@font-size-xl: 20px;
|
||||||
@font-size-xxl: 28px;
|
@font-size-xxl: 28px;
|
||||||
|
|
||||||
// 布局
|
// 布局(容器宽度按设计稿标注校准:设计稿为约 1400 宽的整幅版面)
|
||||||
@container-width: 1200px;
|
@container-width: 1400px;
|
||||||
@header-height: 64px;
|
@header-height: 64px;
|
||||||
@footer-height: 240px;
|
@footer-height: 240px;
|
||||||
|
|
||||||
// 阴影
|
// 阴影
|
||||||
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
|
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
|
||||||
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
|
@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))
|
||||||
|
|||||||
261
src/types/business/cases.d.ts
vendored
261
src/types/business/cases.d.ts
vendored
@@ -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'
|
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 {
|
export interface Case {
|
||||||
@@ -18,21 +61,219 @@ export interface Case {
|
|||||||
industry: string
|
industry: string
|
||||||
duration: string
|
duration: string
|
||||||
clientType: 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
|
video: string
|
||||||
isFavorite: boolean
|
isFavorite: boolean
|
||||||
|
/** 收藏量(详情/列表接口下发,本地收藏翻转时同步 ±1) */
|
||||||
|
favoriteCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 案例筛选条件 */
|
/**
|
||||||
|
* 案例筛选条件。
|
||||||
|
*
|
||||||
|
* <p>三类选项全部由后端下发,前端不写死,且都来自 GET /categories({@link CaseSelectionModel}):
|
||||||
|
* 业务类型({@link CaseTypeOption})、所属行业、项目规模;仅完成日期区间由前端生成。</p>
|
||||||
|
*/
|
||||||
export interface CaseQuery {
|
export interface CaseQuery {
|
||||||
category: BusinessType | 'all'
|
/** 业务类型**提报主键 id**(多选,空数组 = 全部;值域见 CaseTypeOption.value) */
|
||||||
|
categories: string[]
|
||||||
|
/** 所属行业名称(多选,空数组 = 全部;值域见 BusinessIndustryOption.name) */
|
||||||
industries: string[]
|
industries: string[]
|
||||||
scales: ScaleRange[]
|
/** 项目规模档位(多选,空数组 = 全部;整档对象直接下发,值域见 /categories) */
|
||||||
|
scales: ScaleLimit[]
|
||||||
dateRange: [string, string] | null
|
dateRange: [string, string] | null
|
||||||
|
/** 只看收藏(对应后端 isFavorite=1) */
|
||||||
|
favoriteOnly: boolean
|
||||||
keyword: string
|
keyword: string
|
||||||
page: number
|
page: number
|
||||||
pageSize: 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
|
||||||
|
}
|
||||||
|
|||||||
195
src/types/business/demand.d.ts
vendored
195
src/types/business/demand.d.ts
vendored
@@ -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'
|
||||||
|
|
||||||
/** 预算范围 */
|
/** 字典项(GET /system/dict/data/type/{dictType}) */
|
||||||
export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
export interface DictOption {
|
||||||
|
dictLabel: string
|
||||||
/** 需求提报表单 */
|
dictValue: string
|
||||||
export interface Demand {
|
dictSort: number
|
||||||
businessType: BusinessType
|
}
|
||||||
projectName: string
|
|
||||||
attachments: FileInfo[]
|
/** 项目提报列表项(GET /web/projectSubmission/list,只取业务类型卡片用得到的字段) */
|
||||||
coreDescription: string
|
export interface SubmissionBizTypeVo {
|
||||||
expectedDuration: number | ''
|
/** 提报主键(业务详情接口 /web/projectSubmission/{id} 用它) */
|
||||||
budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
id: string | number
|
||||||
userName: string
|
/** 业务类型(既作展示名,也作下发给后端的值) */
|
||||||
userPhone: string
|
businessType: string | null
|
||||||
remark: string
|
/** 标题 */
|
||||||
status: 'draft' | 'submitted'
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
197
src/types/business/home.d.ts
vendored
197
src/types/business/home.d.ts
vendored
@@ -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'
|
import type { BusinessType } from '@/types/api'
|
||||||
|
|
||||||
|
/* ============================ VIEW 类型(页面消费层) ============================ */
|
||||||
|
|
||||||
/** 数据看板 */
|
/** 数据看板 */
|
||||||
export interface HomeStats {
|
export interface HomeStats {
|
||||||
pending: number
|
pending: number
|
||||||
@@ -9,7 +16,7 @@ export interface HomeStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 快捷入口 */
|
/** 快捷入口(VIEW 层占位,实际入口由门户接口驱动) */
|
||||||
export interface QuickLink {
|
export interface QuickLink {
|
||||||
label: string
|
label: string
|
||||||
path: string
|
path: string
|
||||||
@@ -19,6 +26,19 @@ export interface QuickLink {
|
|||||||
export interface ServiceType {
|
export interface ServiceType {
|
||||||
label: string
|
label: string
|
||||||
desc: 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
|
slogan: string
|
||||||
icon: string
|
icon: string
|
||||||
serviceContent: string
|
serviceContent: string
|
||||||
|
/** 服务内容区块列表(一条富文本目录一项,详情页逐块渲染,保留 HTML) */
|
||||||
|
contents: ServiceContentBlock[]
|
||||||
serviceTypes: ServiceType[]
|
serviceTypes: ServiceType[]
|
||||||
video: string
|
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'
|
||||||
|
|||||||
307
src/types/business/project.d.ts
vendored
307
src/types/business/project.d.ts
vendored
@@ -2,8 +2,108 @@
|
|||||||
|
|
||||||
import type { BusinessType, FileInfo } from '@/types/api'
|
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 {
|
export interface Milestone {
|
||||||
@@ -48,7 +148,11 @@ export interface AfterSalesPayload {
|
|||||||
attachments: FileInfo[]
|
attachments: FileInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 项目 */
|
/**
|
||||||
|
* 项目详情(**详情页仍是前端 mock 形态**,尚未接后端)。
|
||||||
|
*
|
||||||
|
* <p>列表已改走真实接口(见 {@link RdProjectVo}),本类型只服务详情页那几个组件。</p>
|
||||||
|
*/
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string
|
id: string
|
||||||
projectNo: string
|
projectNo: string
|
||||||
@@ -68,12 +172,201 @@ export interface Project {
|
|||||||
afterSales?: AfterSales
|
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 {
|
export interface ProjectQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
businessType: BusinessType | 'all'
|
/** '' = 全部;否则为后端统计下发的 businessTypeId */
|
||||||
status: ProjectStatus | 'all'
|
businessType: string
|
||||||
|
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
|
||||||
|
status: string
|
||||||
|
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
|
||||||
timeShortcut: 'all' | '3m' | '6m' | '1y'
|
timeShortcut: 'all' | '3m' | '6m' | '1y'
|
||||||
page: number
|
/** 提交时间自选区间起(YYYY-MM-DD,空串 = 不限) */
|
||||||
|
startDate: string
|
||||||
|
/** 提交时间自选区间止(YYYY-MM-DD,空串 = 不限) */
|
||||||
|
endDate: string
|
||||||
|
pageNum: number
|
||||||
pageSize: 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
|
||||||
|
}
|
||||||
|
|||||||
15
src/types/business/user.d.ts
vendored
15
src/types/business/user.d.ts
vendored
@@ -10,6 +10,21 @@ export interface User {
|
|||||||
passwordModifiedAt: string
|
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 {
|
export interface LoginByAccountParams {
|
||||||
account: string
|
account: string
|
||||||
|
|||||||
7
src/types/router.d.ts
vendored
7
src/types/router.d.ts
vendored
@@ -7,5 +7,12 @@ declare module 'vue-router' {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 是否需要登录 */
|
/** 是否需要登录 */
|
||||||
requiresAuth?: boolean
|
requiresAuth?: boolean
|
||||||
|
/**
|
||||||
|
* 是否展示公共底栏(AppFooter)。
|
||||||
|
*
|
||||||
|
* <p>默认不展示:底栏信息量大、只在门户首页作为站点信息区出现,
|
||||||
|
* 其余页面(列表/详情/登录区)不渲染,避免干扰正文。</p>
|
||||||
|
*/
|
||||||
|
footer?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// 格式化工具(无 Vue 依赖)
|
// 格式化工具(无 Vue 依赖)
|
||||||
|
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessType } from '@/types/api'
|
||||||
import type { ScaleRange } from '@/types/business/cases'
|
|
||||||
|
|
||||||
const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
|
const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
|
||||||
internet: '互联网软件',
|
internet: '互联网软件',
|
||||||
@@ -11,12 +10,23 @@ const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
|
|||||||
other: '其他',
|
other: '其他',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCALE_LABELS: Record<ScaleRange, string> = {
|
const BUSINESS_TYPE_CODES = Object.keys(BUSINESS_TYPE_LABELS) as BusinessType[]
|
||||||
lt50w: '50万以下',
|
|
||||||
'50to100w': '50-100万',
|
/**
|
||||||
'100to300w': '100-300万',
|
* 后端业务类型归一化:兼容英文 code(internet)与中文名称(互联网软件),未知值归 other。
|
||||||
'300to500w': '300-500万',
|
*
|
||||||
gt500w: '500万以上',
|
* <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]
|
return BUSINESS_TYPE_LABELS[type]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 项目规模展示名 */
|
|
||||||
export function formatScale(scale: ScaleRange): string {
|
|
||||||
return SCALE_LABELS[scale]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 金额千分位(默认两位小数) */
|
/** 金额千分位(默认两位小数) */
|
||||||
export function formatMoney(value: number, decimals = 2): string {
|
export function formatMoney(value: number, decimals = 2): string {
|
||||||
const fixed = value.toFixed(decimals)
|
const fixed = value.toFixed(decimals)
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { storage } from '@/utils/storage'
|
|||||||
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
|
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
|
||||||
const SUCCESS_CODE = 200
|
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 实例 */
|
/** 全项目唯一 axios 实例 */
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
@@ -18,6 +28,8 @@ service.interceptors.request.use((config) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
// 带 token 的请求必须回传 clientid,否则后端 SecurityConfig:97-106 校验不通过
|
||||||
|
config.headers.clientid = CLIENT_ID
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -26,6 +38,35 @@ service.interceptors.response.use(undefined, (error) => {
|
|||||||
return Promise.reject(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
|
* 统一请求:自动解包 ApiResponse<T>,失败统一提示并 reject
|
||||||
* 页面/组件禁止直接 import axios,一律经 store 调用本函数
|
* 页面/组件禁止直接 import axios,一律经 store 调用本函数
|
||||||
|
|||||||
@@ -1,164 +1,185 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatBusinessType } from '@/utils/format'
|
import { computed } from 'vue'
|
||||||
import type { Case } from '@/types/business/cases'
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Case
|
item: Case
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
favorite: [id: string]
|
favorite: [id: string]
|
||||||
share: [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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="case-header">
|
<section class="overview">
|
||||||
<div class="case-header__main">
|
<h3 class="overview__title">
|
||||||
<div class="case-header__head">
|
项目概览
|
||||||
<h1 class="case-header__title">
|
</h3>
|
||||||
{{ item.title }}
|
|
||||||
</h1>
|
<div class="overview__body">
|
||||||
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
|
<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>
|
||||||
|
|
||||||
<div class="case-header__actions">
|
<div class="overview__actions">
|
||||||
<button
|
<button
|
||||||
class="case-header__btn"
|
class="overview__pill"
|
||||||
:class="{ 'is-favorite': item.isFavorite }"
|
:class="{ 'is-active': props.item.isFavorite }"
|
||||||
@click="emit('favorite', item.id)"
|
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>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</section>
|
||||||
<div class="case-header__video">
|
|
||||||
<!-- TODO(蓝湖): 接入真实视频源 -->
|
|
||||||
<span>视频将在预览时播放</span>
|
|
||||||
<span class="case-header__video-time">0:00 / 0:00</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-header {
|
.overview {
|
||||||
.flex(row, flex-start);
|
padding: @spacing-lg;
|
||||||
gap: @spacing-lg;
|
border-radius: @radius-base;
|
||||||
padding: @spacing-xl;
|
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
|
||||||
|
|
||||||
&__main {
|
&__title {
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
}
|
font-size: @font-size-lg;
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: @font-size-xl;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
|
||||||
|
|
||||||
&__tag {
|
&::before {
|
||||||
padding: 2px @spacing-sm;
|
content: '';
|
||||||
border-radius: @radius-round;
|
width: 4px;
|
||||||
background: @primary-light;
|
height: 16px;
|
||||||
color: @primary-color;
|
border-radius: 2px;
|
||||||
font-size: @font-size-sm;
|
background: @primary-color;
|
||||||
}
|
|
||||||
|
|
||||||
&__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 {
|
&__body {
|
||||||
border-color: @warning-color;
|
.flex(row, space-between, center);
|
||||||
color: @warning-color;
|
gap: @spacing-lg;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
display: grid;
|
// 显式 stretch:否则元信息行会被居中收缩(设计稿为左起铺满整行)
|
||||||
grid-template-columns: repeat(3, 1fr);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
flex: 1;
|
||||||
|
gap: @spacing-sm;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta-item {
|
&__row {
|
||||||
.flex(column, flex-start);
|
.flex(row, space-between, center);
|
||||||
gap: 4px;
|
gap: @spacing-base;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__video {
|
&__actions {
|
||||||
.flex(column, center);
|
.flex(row, flex-start, center);
|
||||||
|
flex-shrink: 0;
|
||||||
gap: @spacing-sm;
|
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 {
|
&__pill {
|
||||||
color: @text-color-disabled;
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -5,53 +5,84 @@ interface Props {
|
|||||||
item: Case
|
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' },
|
* <p>区块的**数量与标题都来自后端**(vm_project_case_catalog:title + content,
|
||||||
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
|
* 按 sort_order 升序),前端不再写死「项目背景 / 客户痛点 / 需求描述 / 交付物」,
|
||||||
] as const
|
* 后端增删目录即自动生效。空内容已在 utils/caseVo.ts#toSections 里过滤。</p>
|
||||||
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="case-rich">
|
<div class="case-rich">
|
||||||
<section v-for="section in sections" :key="section.key" class="case-rich__section">
|
<section
|
||||||
<h3 class="case-rich__title">
|
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 }}
|
{{ section.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="case-rich__text">{{ item[section.field] }}</p>
|
<!-- 后端下发富文本 HTML(可能内嵌 img / video),直接渲染 -->
|
||||||
|
<div class="case-rich__html" v-html="section.content" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-rich {
|
.case-rich {
|
||||||
.flex(column, flex-start);
|
// 同 detail.vue:显式 stretch,避免正文块被压成内容宽
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
|
|
||||||
&__section {
|
&__card {
|
||||||
padding: @spacing-lg @spacing-xl;
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin-bottom: @spacing-sm;
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
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;
|
font-size: @font-size-base;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
white-space: pre-wrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ElDatePicker } from 'element-plus'
|
import { ElDatePicker } from 'element-plus'
|
||||||
import { FilterPanel, type FilterChip } from '@/components'
|
import type { FilterChip } from '@/components'
|
||||||
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
|
import { formatDate } from '@/utils/format'
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } from '@/types/business/cases'
|
||||||
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: CaseQuery
|
query: CaseQuery
|
||||||
counts: Partial<Record<BusinessType, number>>
|
/** 业务类型选项(接口下发:/categories 的统计;value=提报 id,label=类型名) */
|
||||||
total: number
|
typeOptions: CaseTypeOption[]
|
||||||
|
/** 所属行业选项(接口下发:/categories) */
|
||||||
|
industryOptions: BusinessIndustryOption[]
|
||||||
|
/** 项目规模选项(接口下发:/categories,label 已由后端拼好) */
|
||||||
|
scaleOptions: ScaleLimit[]
|
||||||
|
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
|
||||||
|
caseTotal: number
|
||||||
|
/** 待提交条件的结果数(「确定筛选(N 个结果)」) */
|
||||||
|
previewTotal: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -22,282 +29,405 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
|
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
|
||||||
|
|
||||||
const activeShortcut = ref<TimeShortcut>('all')
|
/** 完成时间后端未下发选项,保留前端快捷区间 */
|
||||||
|
const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [
|
||||||
watch(
|
{ label: '全部', value: 'all', months: 0 },
|
||||||
() => props.query.dateRange,
|
{ label: '近3个月', value: '3m', months: 3 },
|
||||||
(value) => {
|
{ label: '近半年', value: '6m', months: 6 },
|
||||||
if (!value) activeShortcut.value = 'all'
|
{ label: '近一年', value: '1y', months: 12 },
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
|
|
||||||
{ label: '全部', value: 'all' },
|
|
||||||
{ label: '互联网软件', value: 'internet' },
|
|
||||||
{ label: '硬件电路', value: 'hardware' },
|
|
||||||
{ label: '物联网全案', value: 'iot' },
|
|
||||||
{ label: '生产制造', value: 'manufacturing' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const industryOptions = [
|
/**
|
||||||
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
|
* 规模档位的稳定标识。
|
||||||
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
|
*
|
||||||
]
|
* <p>后端档位是「上下限」对象(label 只是展示文字),用上下限拼 key 比 label 可靠:
|
||||||
|
* 区间值唯一、且不依赖后端文案。</p>
|
||||||
|
*/
|
||||||
|
function scaleKey(item: ScaleLimit): string {
|
||||||
|
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
|
||||||
|
}
|
||||||
|
|
||||||
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
|
function scaleLabel(item: ScaleLimit): string {
|
||||||
{ label: '50万以下', value: 'lt50w' },
|
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
|
||||||
{ label: '50-100万', value: '50to100w' },
|
}
|
||||||
{ label: '100-300万', value: '100to300w' },
|
|
||||||
{ label: '300-500万', value: '300to500w' },
|
|
||||||
{ label: '500万以上', value: 'gt500w' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
|
/** 快捷区间 → 具体日期区间 */
|
||||||
{ label: '全部', value: 'all' },
|
function rangeOf(months: number): [string, string] {
|
||||||
{ label: '近3个月', value: '3m' },
|
const end = new Date()
|
||||||
{ label: '近半年', value: '6m' },
|
const start = new Date()
|
||||||
{ label: '近一年', value: '1y' },
|
start.setMonth(start.getMonth() - months)
|
||||||
]
|
return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')]
|
||||||
|
}
|
||||||
|
|
||||||
const keyword = computed({
|
/** 当前区间命中的快捷项(用于选中态回显,避免额外维护一份状态) */
|
||||||
get: () => props.query.keyword,
|
const activeShortcut = computed<TimeShortcut>(() => {
|
||||||
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
|
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 chips = computed<FilterChip[]>(() => {
|
||||||
const list: FilterChip[] = []
|
const result: FilterChip[] = []
|
||||||
if (props.query.category !== 'all') {
|
for (const value of props.query.categories) {
|
||||||
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
|
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) {
|
for (const name of props.query.industries) {
|
||||||
list.push({ key: `industry:${industry}`, label: industry })
|
result.push({ key: `industry:${name}`, label: name })
|
||||||
}
|
}
|
||||||
for (const scale of props.query.scales) {
|
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) {
|
if (props.query.dateRange) {
|
||||||
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
const label = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
||||||
list.push({
|
result.push({
|
||||||
key: 'time',
|
key: 'time',
|
||||||
label: shortcutLabel && shortcutLabel !== '全部'
|
label: label ?? `${props.query.dateRange[0]}-${props.query.dateRange[1]}`,
|
||||||
? shortcutLabel
|
|
||||||
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return list
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
function patch(partial: Partial<CaseQuery>): void {
|
function patch(partial: Partial<CaseQuery>): void {
|
||||||
emit('update:query', { ...props.query, ...partial })
|
emit('update:query', { ...props.query, ...partial })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCategory(value: BusinessType | 'all'): void {
|
function toggleIn<T>(list: T[], value: T): T[] {
|
||||||
patch({ category: value })
|
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleIndustry(industry: string): void {
|
function toggleScale(item: ScaleLimit): void {
|
||||||
const next = props.query.industries.includes(industry)
|
const key = scaleKey(item)
|
||||||
? props.query.industries.filter((item) => item !== industry)
|
const exists = props.query.scales.some((scale) => scaleKey(scale) === key)
|
||||||
: [...props.query.industries, industry]
|
patch({
|
||||||
patch({ industries: next })
|
scales: exists
|
||||||
|
? props.query.scales.filter((scale) => scaleKey(scale) !== key)
|
||||||
|
: [...props.query.scales, { ...item }],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleScale(scale: ScaleRange): void {
|
function setShortcut(value: Exclude<TimeShortcut, 'custom'>): 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
|
|
||||||
if (value === 'all') {
|
if (value === 'all') {
|
||||||
patch({ dateRange: null })
|
patch({ dateRange: null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const end = new Date()
|
const shortcut = timeShortcuts.find((item) => item.value === value)
|
||||||
const start = new Date()
|
if (shortcut) patch({ dateRange: rangeOf(shortcut.months) })
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRemoveChip(key: string): void {
|
function onRemoveChip(key: string): void {
|
||||||
if (key === 'category') {
|
if (key === 'time') {
|
||||||
setCategory('all')
|
|
||||||
}
|
|
||||||
else if (key === 'time') {
|
|
||||||
activeShortcut.value = 'all'
|
|
||||||
patch({ dateRange: null })
|
patch({ dateRange: null })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
else if (key.startsWith('industry:')) {
|
if (key.startsWith('category:')) {
|
||||||
const industry = key.slice('industry:'.length)
|
const value = key.slice('category:'.length)
|
||||||
patch({ industries: props.query.industries.filter((item) => item !== industry) })
|
patch({ categories: props.query.categories.filter((item) => item !== value) })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
else if (key.startsWith('scale:')) {
|
if (key.startsWith('industry:')) {
|
||||||
const scale = key.slice('scale:'.length) as ScaleRange
|
const name = key.slice('industry:'.length)
|
||||||
patch({ scales: props.query.scales.filter((item) => item !== scale) })
|
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 {
|
function clearChips(): void {
|
||||||
activeShortcut.value = 'all'
|
patch({ categories: [], industries: [], scales: [], dateRange: null })
|
||||||
emit('update:query', {
|
|
||||||
...props.query,
|
|
||||||
category: 'all',
|
|
||||||
industries: [],
|
|
||||||
scales: [],
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FilterPanel
|
<aside class="case-filter">
|
||||||
v-model:keyword="keyword"
|
<div class="case-filter__head">
|
||||||
:chips="chips"
|
<h2 class="case-filter__title">
|
||||||
:show-result-count="true"
|
筛选条件
|
||||||
:result-count="total"
|
</h2>
|
||||||
search-label="确定筛选"
|
<button class="case-filter__link" @click="emit('reset')">
|
||||||
@search="emit('search')"
|
重置
|
||||||
@reset="emit('reset')"
|
</button>
|
||||||
@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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 所属行业(多选) -->
|
<!-- 业务类型(接口下发;带案例数) -->
|
||||||
<div class="cases-filter__group">
|
<section class="case-filter__group">
|
||||||
<span class="cases-filter__group-title">所属行业</span>
|
<h3 class="case-filter__group-title">
|
||||||
<div class="cases-filter__options">
|
业务类型
|
||||||
|
</h3>
|
||||||
|
<ul class="case-filter__rows">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
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
|
<button
|
||||||
class="cases-filter__option"
|
class="case-filter__pill"
|
||||||
:class="{ 'is-active': query.industries.length === 0 }"
|
:class="{ 'is-light': query.industries.length === 0 }"
|
||||||
@click="patch({ industries: [] })"
|
@click="patch({ industries: [] })"
|
||||||
>
|
>
|
||||||
全部
|
全部
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="industry in industryOptions"
|
v-for="option in industryOptions"
|
||||||
:key="industry"
|
:key="option.name ?? ''"
|
||||||
class="cases-filter__option"
|
class="case-filter__pill"
|
||||||
:class="{ 'is-active': query.industries.includes(industry) }"
|
:class="{ 'is-active': query.industries.includes(option.name ?? '') }"
|
||||||
@click="toggleIndustry(industry)"
|
@click="patch({ industries: toggleIn(query.industries, option.name ?? '') })"
|
||||||
>
|
>
|
||||||
{{ industry }}
|
{{ option.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- 项目规模(多选) -->
|
<!-- 项目规模(接口下发,多选) -->
|
||||||
<div class="cases-filter__group">
|
<section class="case-filter__group">
|
||||||
<span class="cases-filter__group-title">项目规模</span>
|
<h3 class="case-filter__group-title">
|
||||||
<div class="cases-filter__options">
|
项目规模
|
||||||
|
</h3>
|
||||||
|
<div class="case-filter__pills">
|
||||||
<button
|
<button
|
||||||
class="cases-filter__option"
|
class="case-filter__pill"
|
||||||
:class="{ 'is-active': query.scales.length === 0 }"
|
:class="{ 'is-light': query.scales.length === 0 }"
|
||||||
@click="patch({ scales: [] })"
|
@click="patch({ scales: [] })"
|
||||||
>
|
>
|
||||||
全部
|
全部
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="scale in scaleOptions"
|
v-for="option in scaleOptions"
|
||||||
:key="scale.value"
|
:key="scaleKey(option)"
|
||||||
class="cases-filter__option"
|
class="case-filter__pill"
|
||||||
:class="{ 'is-active': query.scales.includes(scale.value) }"
|
:class="{ 'is-active': query.scales.some((item) => scaleKey(item) === scaleKey(option)) }"
|
||||||
@click="toggleScale(scale.value)"
|
@click="toggleScale(option)"
|
||||||
>
|
>
|
||||||
{{ scale.label }}
|
{{ scaleLabel(option) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- 完成时间(快捷区间 + 自定义) -->
|
<!-- 完成时间(快捷区间 + 自定义;后端未下发该组选项) -->
|
||||||
<div class="cases-filter__group">
|
<section class="case-filter__group">
|
||||||
<span class="cases-filter__group-title">完成时间</span>
|
<h3 class="case-filter__group-title">
|
||||||
<div class="cases-filter__options">
|
完成时间
|
||||||
|
</h3>
|
||||||
|
<div class="case-filter__pills">
|
||||||
<button
|
<button
|
||||||
v-for="shortcut in timeShortcuts"
|
v-for="shortcut in timeShortcuts"
|
||||||
:key="shortcut.value"
|
:key="shortcut.value"
|
||||||
class="cases-filter__option"
|
class="case-filter__pill"
|
||||||
:class="{ 'is-active': activeShortcut === shortcut.value }"
|
:class="{
|
||||||
|
'is-light': shortcut.value === 'all' && activeShortcut === 'all',
|
||||||
|
'is-active': shortcut.value !== 'all' && activeShortcut === shortcut.value,
|
||||||
|
}"
|
||||||
@click="setShortcut(shortcut.value)"
|
@click="setShortcut(shortcut.value)"
|
||||||
>
|
>
|
||||||
{{ shortcut.label }}
|
{{ shortcut.label }}
|
||||||
</button>
|
</button>
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="query.dateRange"
|
v-model="dateRange"
|
||||||
|
class="case-filter__date"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="—"
|
range-separator="—"
|
||||||
start-placeholder="开始时间"
|
start-placeholder="开始时间"
|
||||||
end-placeholder="结束时间"
|
end-placeholder="结束时间"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
class="cases-filter__date"
|
/>
|
||||||
@change="onDateRange"
|
</div>
|
||||||
/> </div>
|
</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>
|
</div>
|
||||||
</FilterPanel>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<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 {
|
&__group {
|
||||||
.flex(row, flex-start);
|
margin-top: @spacing-lg;
|
||||||
gap: @spacing-base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__group-title {
|
&__group-title {
|
||||||
flex-shrink: 0;
|
|
||||||
width: 70px;
|
|
||||||
padding-top: 5px;
|
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__options {
|
/* 业务类型:整行可点,选中整行铺满底色 */
|
||||||
.flex(row, flex-start, center);
|
&__rows {
|
||||||
flex-wrap: wrap;
|
margin-top: @spacing-sm;
|
||||||
gap: @spacing-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__option {
|
&__row {
|
||||||
height: 28px;
|
.flex(row, flex-start, center);
|
||||||
padding: 0 @spacing-sm;
|
gap: @spacing-sm;
|
||||||
border: 1px solid transparent;
|
width: 100%;
|
||||||
border-radius: @radius-sm;
|
height: 36px;
|
||||||
background: @bg-color;
|
padding: 0 @spacing-base;
|
||||||
color: @text-color;
|
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;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -305,20 +435,140 @@ function onClearChips(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @primary-color;
|
background: @primary-color;
|
||||||
background: @primary-light;
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-light {
|
||||||
|
background: @pill-selected-bg;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__date {
|
||||||
margin-left: 4px;
|
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;
|
color: @text-color-secondary;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__date {
|
&__chips {
|
||||||
width: 240px;
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -4,23 +4,34 @@ import type { Case } from '@/types/business/cases'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cases: Case[]
|
cases: Case[]
|
||||||
|
/** 当前筛选条件下的案例总数 */
|
||||||
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{ view: [id: string] }>()
|
const emit = defineEmits<{
|
||||||
|
view: [id: string]
|
||||||
|
favorite: [id: string]
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="cases-grid">
|
<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
|
<CaseCard
|
||||||
v-for="item in cases"
|
v-for="item in cases"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:item="item"
|
:item="item"
|
||||||
@view="emit('view', $event)"
|
@view="emit('view', $event)"
|
||||||
|
@favorite="emit('favorite', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<p v-else class="cases-grid__empty text-secondary">
|
<p v-else class="cases-grid__empty text-secondary">
|
||||||
暂无符合条件的案例,请调整筛选条件
|
暂无符合条件的案例,请调整筛选条件
|
||||||
</p>
|
</p>
|
||||||
@@ -29,12 +40,20 @@ const emit = defineEmits<{ view: [id: string] }>()
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.cases-grid {
|
.cases-grid {
|
||||||
display: grid;
|
&__total {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
font-size: 24px;
|
||||||
gap: @spacing-base;
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: @spacing-base;
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: @spacing-xxl 0;
|
padding: @spacing-xxl 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { AppButton } from '@/components'
|
|
||||||
// 页面容器:数据只经 store,不直接 import api
|
// 页面容器:数据只经 store,不直接 import api
|
||||||
import { useCasesStore } from '@/stores/modules/cases'
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
|
import CaseHero from './cpns/CaseHero.vue'
|
||||||
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
|
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
|
||||||
import CaseRichText from './cpns/CaseRichText.vue'
|
import CaseRichText from './cpns/CaseRichText.vue'
|
||||||
|
|
||||||
@@ -41,22 +41,32 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="case-detail">
|
<div class="case-detail">
|
||||||
<nav class="case-detail__breadcrumb text-secondary">
|
<div class="container case-detail__crumb">
|
||||||
<RouterLink to="/cases" class="link">
|
<nav class="text-secondary">
|
||||||
案例库
|
<RouterLink to="/cases" class="link">
|
||||||
</RouterLink>
|
案例库
|
||||||
<span> > </span>
|
</RouterLink>
|
||||||
<span>案例详情</span>
|
<span> > </span>
|
||||||
</nav>
|
<span>案例详情</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="item">
|
<template v-if="item">
|
||||||
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
<!-- 封面通栏:设计稿中不被内容容器收窄 -->
|
||||||
<CaseRichText :item="item" />
|
<CaseHero :item="item" />
|
||||||
|
|
||||||
<div class="case-detail__cta">
|
<div class="container case-detail__main">
|
||||||
<AppButton type="primary" size="lg" @click="router.push('/demand')">
|
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
||||||
|
<CaseRichText :item="item" />
|
||||||
|
|
||||||
|
<!-- 设计稿:内容区整宽蓝色按钮 -->
|
||||||
|
<button
|
||||||
|
class="case-detail__cta"
|
||||||
|
type="button"
|
||||||
|
@click="router.push('/demand')"
|
||||||
|
>
|
||||||
一键填报类似案例
|
一键填报类似案例
|
||||||
</AppButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -68,17 +78,35 @@ onMounted(load)
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.case-detail {
|
.case-detail {
|
||||||
padding-top: @spacing-lg;
|
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__crumb {
|
||||||
margin-bottom: @spacing-base;
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ .flex 第三个参数是 align(默认 center),必须显式传 stretch,
|
||||||
|
// 否则各白块会被压成「内容宽 + 水平居中」(设计稿要求整宽)
|
||||||
|
&__main {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: @spacing-base;
|
||||||
|
padding-top: @spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
&__cta {
|
&__cta {
|
||||||
.flex(row, center);
|
width: 100%;
|
||||||
padding-top: @spacing-base;
|
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 {
|
&__empty {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export { default } from './index.vue'
|
export { default } from './index.vue'
|
||||||
export { default as CasesFilter } from './cpns/CasesFilter.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 CasesGrid } from './cpns/CasesGrid.vue'
|
||||||
|
export { default as CaseHero } from './cpns/CaseHero.vue'
|
||||||
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
||||||
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ import { PaginationBar } from '@/components'
|
|||||||
import { useCasesStore } from '@/stores/modules/cases'
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
import type { CaseQuery } from '@/types/business/cases'
|
import type { CaseQuery } from '@/types/business/cases'
|
||||||
import CasesFilter from './cpns/CasesFilter.vue'
|
import CasesFilter from './cpns/CasesFilter.vue'
|
||||||
|
import CasesSearchBar from './cpns/CasesSearchBar.vue'
|
||||||
import CasesGrid from './cpns/CasesGrid.vue'
|
import CasesGrid from './cpns/CasesGrid.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const casesStore = useCasesStore()
|
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 {
|
function onUpdateQuery(next: CaseQuery): void {
|
||||||
casesStore.setQuery(next)
|
casesStore.setQuery(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onUpdateKeyword(keyword: string): void {
|
||||||
|
casesStore.setQuery({ ...query.value, keyword })
|
||||||
|
}
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
void casesStore.search()
|
void casesStore.search()
|
||||||
}
|
}
|
||||||
@@ -25,6 +31,14 @@ function onReset(): void {
|
|||||||
void casesStore.resetQuery()
|
void casesStore.resetQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onToggleFavoriteOnly(): void {
|
||||||
|
void casesStore.toggleFavoriteOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFavorite(id: string): void {
|
||||||
|
void casesStore.toggleFavorite(id)
|
||||||
|
}
|
||||||
|
|
||||||
function onPageChange(page: number): void {
|
function onPageChange(page: number): void {
|
||||||
void casesStore.changePage(page)
|
void casesStore.changePage(page)
|
||||||
}
|
}
|
||||||
@@ -34,48 +48,76 @@ function onViewDetail(id: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
|
// 筛选面板选项全部来自接口:业务类型 / 所属行业 / 项目规模三组都走 /categories,完成时间由前端算
|
||||||
|
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="cases">
|
<div class="cases">
|
||||||
<header class="cases__head">
|
<header class="cases__hero">
|
||||||
<h1 class="cases__title">
|
<div class="container">
|
||||||
成功案例库
|
<h1 class="cases__title">
|
||||||
</h1>
|
成功案例库
|
||||||
<p class="cases__slogan text-secondary">
|
</h1>
|
||||||
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
|
<p class="cases__slogan">
|
||||||
</p>
|
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<CasesFilter
|
<div class="container cases__body">
|
||||||
:query="query"
|
<CasesFilter
|
||||||
:counts="counts"
|
:query="query"
|
||||||
:total="total"
|
:type-options="typeOptions"
|
||||||
@update:query="onUpdateQuery"
|
:industry-options="industryOptions"
|
||||||
@search="onSearch"
|
:scale-options="scaleOptions"
|
||||||
@reset="onReset"
|
: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"
|
||||||
|
/>
|
||||||
|
|
||||||
<PaginationBar
|
<CasesGrid
|
||||||
:page="query.page"
|
:cases="list"
|
||||||
:page-size="query.pageSize"
|
:total="total"
|
||||||
:total="total"
|
@view="onViewDetail"
|
||||||
@page-change="onPageChange"
|
@favorite="onFavorite"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="cases__pager">
|
||||||
|
<PaginationBar
|
||||||
|
:page="query.page"
|
||||||
|
:page-size="query.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.cases {
|
.cases {
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
padding-top: @spacing-xl;
|
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
|
&__hero {
|
||||||
|
padding: @spacing-lg 0;
|
||||||
|
background: @white;
|
||||||
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xxl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -85,6 +127,24 @@ onMounted(() => {
|
|||||||
&__slogan {
|
&__slogan {
|
||||||
margin-top: @spacing-xs;
|
margin-top: @spacing-xs;
|
||||||
font-size: @font-size-base;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export { default } from './index.vue'
|
export { default } from './index.vue'
|
||||||
export { default as DemandStep1Business } from './cpns/DemandStep1Business.vue'
|
export { default as DemandBizTypes } from './cpns/DemandBizTypes.vue'
|
||||||
export { default as DemandStep2Info } from './cpns/DemandStep2Info.vue'
|
export { default as DemandInfoForm } from './cpns/DemandInfoForm.vue'
|
||||||
export { default as DemandStep3Confirm } from './cpns/DemandStep3Confirm.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'
|
||||||
|
|||||||
@@ -1,131 +1,143 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// 需求提报(单页三段卡片 + 底部固定操作条,数据全部走真实接口)
|
||||||
|
// 页面 → store → api;页面不直接 import api
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { AppButton, StepForm } from '@/components'
|
|
||||||
import { useDemandStore } from '@/stores/modules/demand'
|
import { useDemandStore } from '@/stores/modules/demand'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import type { Demand, DemandAttachment } from '@/types/business/demand'
|
||||||
import type { BusinessType } from '@/types/api'
|
import DemandActionBar from './cpns/DemandActionBar.vue'
|
||||||
import type { Demand } from '@/types/business/demand'
|
import DemandBizTypes from './cpns/DemandBizTypes.vue'
|
||||||
import DemandStep1Business from './cpns/DemandStep1Business.vue'
|
import DemandConfirmForm from './cpns/DemandConfirmForm.vue'
|
||||||
import DemandStep2Info from './cpns/DemandStep2Info.vue'
|
import DemandInfoForm from './cpns/DemandInfoForm.vue'
|
||||||
import DemandStep3Confirm from './cpns/DemandStep3Confirm.vue'
|
|
||||||
|
|
||||||
const STEP_TITLES = ['选择业务类型', '填写需求信息', '确认信息并补充备注']
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const demandStore = useDemandStore()
|
const demandStore = useDemandStore()
|
||||||
const userStore = useUserStore()
|
const {
|
||||||
const { form, step, loading } = storeToRefs(demandStore)
|
form,
|
||||||
|
bizTypeOptions,
|
||||||
|
budgetOptions,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
submitting,
|
||||||
|
uploading,
|
||||||
|
} = storeToRefs(demandStore)
|
||||||
|
|
||||||
function onSelectBusiness(type: BusinessType): void {
|
function onBizType(value: string): void {
|
||||||
demandStore.patchForm({ businessType: type })
|
demandStore.patchForm({ bizType: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateDemand(partial: Partial<Demand>): void {
|
function onUpdate(partial: Partial<Demand>): void {
|
||||||
demandStore.patchForm(partial)
|
demandStore.patchForm(partial)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpload(file: File): void {
|
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)
|
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 {
|
function onSaveDraft(): void {
|
||||||
void demandStore.saveDraft()
|
void demandStore.saveDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset(): void {
|
function onReset(): void {
|
||||||
demandStore.reset()
|
void demandStore.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
await demandStore.submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void demandStore.loadDraft()
|
void demandStore.init()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="demand">
|
<div class="demand-page">
|
||||||
<header class="demand__head">
|
<div class="container demand" :class="{ 'is-loading': loading }">
|
||||||
<h1 class="demand__title">
|
<header class="demand__head">
|
||||||
需求提报
|
<h1 class="demand__title">
|
||||||
</h1>
|
需求提报
|
||||||
<p class="demand__subtitle text-secondary">
|
</h1>
|
||||||
填写您的需求信息,专业顾问将快速与您联系
|
<p class="demand__subtitle">
|
||||||
</p>
|
填写您的需求信息,专业顾问将快速与您联系
|
||||||
</header>
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<StepForm :titles="STEP_TITLES" :current="step - 1">
|
<!-- ① 选择业务类型 -->
|
||||||
<DemandStep1Business
|
<section class="demand__card">
|
||||||
v-if="step === 1"
|
<h2 class="demand__section">
|
||||||
:demand="form"
|
<span class="demand__index">1</span>
|
||||||
@select="onSelectBusiness"
|
选择业务类型<span class="demand__required">*</span>
|
||||||
/>
|
</h2>
|
||||||
<DemandStep2Info
|
<DemandBizTypes
|
||||||
v-else-if="step === 2"
|
:options="bizTypeOptions"
|
||||||
:demand="form"
|
:model-value="form.bizType"
|
||||||
@update:demand="onUpdateDemand"
|
@update:model-value="onBizType"
|
||||||
@upload="onUpload"
|
/>
|
||||||
@remove-file="onRemoveFile"
|
</section>
|
||||||
/>
|
|
||||||
<DemandStep3Confirm
|
|
||||||
v-else
|
|
||||||
:demand="form"
|
|
||||||
@update:demand="onUpdateDemand"
|
|
||||||
/>
|
|
||||||
</StepForm>
|
|
||||||
|
|
||||||
<div class="demand__nav">
|
<!-- ② 填写需求信息 -->
|
||||||
<AppButton :disabled="step === 1 || loading" @click="goPrev">
|
<section class="demand__card">
|
||||||
上一步
|
<h2 class="demand__section">
|
||||||
</AppButton>
|
<span class="demand__index">2</span>
|
||||||
<AppButton v-if="step < 3" type="primary" :disabled="loading" @click="goNext">
|
填写需求信息<span class="demand__required">*</span>
|
||||||
下一步
|
</h2>
|
||||||
</AppButton>
|
<DemandInfoForm
|
||||||
|
:demand="form"
|
||||||
|
:budget-options="budgetOptions"
|
||||||
|
:uploading="uploading"
|
||||||
|
@update="onUpdate"
|
||||||
|
@upload="onUpload"
|
||||||
|
@remove-file="onRemoveFile"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ③ 确认信息并补充备注 -->
|
||||||
|
<section class="demand__card">
|
||||||
|
<h2 class="demand__section">
|
||||||
|
<span class="demand__index">3</span>
|
||||||
|
确认信息并补充备注
|
||||||
|
</h2>
|
||||||
|
<DemandConfirmForm
|
||||||
|
:demand="form"
|
||||||
|
@update="onUpdate"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="demand__actions">
|
<DemandActionBar
|
||||||
<AppButton type="primary" size="lg" :loading="loading" @click="onSubmit">
|
:saving="saving"
|
||||||
立即提报
|
:submitting="submitting"
|
||||||
</AppButton>
|
@save-draft="onSaveDraft"
|
||||||
<AppButton size="lg" :disabled="loading" @click="onSaveDraft">
|
@reset="onReset"
|
||||||
保存草稿
|
@submit="onSubmit"
|
||||||
</AppButton>
|
/>
|
||||||
<AppButton size="lg" :disabled="loading" @click="onReset">
|
|
||||||
重置
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.demand {
|
.demand-page {
|
||||||
.flex(column, flex-start);
|
padding-top: @spacing-lg;
|
||||||
gap: @spacing-base;
|
// 固定底条 72px + 呼吸间距,避免遮住 ③ 卡片
|
||||||
padding-top: @spacing-xl;
|
padding-bottom: 104px;
|
||||||
padding-bottom: @spacing-xxl;
|
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
.demand {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demand {
|
||||||
|
// 设计稿:页头下与首块间距 ≈ 20px
|
||||||
&__head {
|
&__head {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@@ -136,16 +148,44 @@ onMounted(() => {
|
|||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__nav {
|
&__card {
|
||||||
.flex(row, space-between);
|
padding: @spacing-lg;
|
||||||
width: 100%;
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
|
||||||
|
// 设计稿块间距 18px
|
||||||
|
& + & {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__section {
|
||||||
.flex(row, center);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-base;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -16,24 +16,28 @@ defineProps<Props>()
|
|||||||
</h3>
|
</h3>
|
||||||
<span class="home-business__slogan text-secondary">研发智造一体化自营平台,全链路质量兜底</span>
|
<span class="home-business__slogan text-secondary">研发智造一体化自营平台,全链路质量兜底</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-business__grid">
|
<div class="home-business__grid">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="line in lines"
|
v-for="line in lines"
|
||||||
:key="line.id"
|
:key="line.id"
|
||||||
:to="`/business/${line.id}`"
|
:to="`/business/${line.id}`"
|
||||||
class="home-business__card"
|
class="home-business__card"
|
||||||
|
:class="`home-business__card--${line.id}`"
|
||||||
>
|
>
|
||||||
<div class="home-business__icon">
|
<div class="home-business__card-head">
|
||||||
<!-- TODO(蓝湖): 替换业务图标切图 -->
|
<span class="home-business__icon">
|
||||||
<span>{{ line.name.slice(0, 2) }}</span>
|
<!-- TODO(蓝湖): 替换业务图标切图 -->
|
||||||
|
{{ line.name.slice(0, 2) }}
|
||||||
|
</span>
|
||||||
|
<h4 class="home-business__name">
|
||||||
|
{{ line.name }}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="home-business__name">
|
<p class="home-business__desc">
|
||||||
{{ line.name }}
|
|
||||||
</h4>
|
|
||||||
<p class="home-business__desc text-secondary">
|
|
||||||
{{ line.desc }}
|
{{ line.desc }}
|
||||||
</p>
|
</p>
|
||||||
<span class="home-business__more">了解详细 ></span>
|
<span class="home-business__more">了解详情 ></span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -64,29 +68,31 @@ defineProps<Props>()
|
|||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-base;
|
||||||
padding: @spacing-lg;
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: @primary-light;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: @shadow-lg;
|
box-shadow: @shadow-lg;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
.home-business__more {
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__card-head {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
width: 48px;
|
flex-shrink: 0;
|
||||||
height: 48px;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @primary-light;
|
background: @white;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -101,13 +107,68 @@ defineProps<Props>()
|
|||||||
&__desc {
|
&__desc {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
.multi-ellipsis(2);
|
.multi-ellipsis(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__more {
|
&__more {
|
||||||
|
align-self: flex-end;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
transition: color 0.2s;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { CaseCard } from '@/components'
|
import { formatBusinessType } from '@/utils/format'
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessType } from '@/types/api'
|
||||||
import type { Case } from '@/types/business/cases'
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
@@ -46,16 +46,39 @@ function switchCategory(value: BusinessType | 'all'): void {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink to="/cases" class="home-cases__more link">
|
<RouterLink to="/cases" class="home-cases__more link">
|
||||||
查看更多 >
|
查看全部 >
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-cases__grid">
|
<div class="home-cases__grid">
|
||||||
<CaseCard
|
<article
|
||||||
v-for="item in cases"
|
v-for="item in cases"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:item="item"
|
class="home-cases__card"
|
||||||
@view="$router.push(`/cases/${$event}`)"
|
@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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,6 +87,7 @@ function switchCategory(value: BusinessType | 'all'): void {
|
|||||||
.home-cases {
|
.home-cases {
|
||||||
&__head {
|
&__head {
|
||||||
.flex(row, space-between);
|
.flex(row, space-between);
|
||||||
|
gap: @spacing-base;
|
||||||
margin-bottom: @spacing-base;
|
margin-bottom: @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +99,7 @@ function switchCategory(value: BusinessType | 'all'): void {
|
|||||||
|
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
|
flex: 1;
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +127,103 @@ function switchCategory(value: BusinessType | 'all'): void {
|
|||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: @spacing-base;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,70 +1,185 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 快捷入口为固定模块链接(纯 UI,静态数据)
|
import { computed, ref } from 'vue'
|
||||||
const entries = [
|
import { storeToRefs } from 'pinia'
|
||||||
{ label: '财务台账', path: '/finance/bills', icon: '💰' },
|
import { useRouter } from 'vue-router'
|
||||||
{ label: '合同台账', path: '/contract', icon: '📄' },
|
import { useHomeStore } from '@/stores/modules/home'
|
||||||
{ label: '客服工作台', path: '/service', icon: '💬' },
|
import type { PortalEntryCard } from '@/types/business/home'
|
||||||
{ label: '案例库管理', path: '/cases', icon: '📁' },
|
import { toPortalEntryCards } from '@/utils/entryIcon'
|
||||||
{ label: '工单需求池', path: '/demand', icon: '📋' },
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="home-quick">
|
<section class="home-quick">
|
||||||
<h3 class="home-quick__title">
|
<div class="home-quick__head">
|
||||||
快捷入口
|
<span class="home-quick__head-icon">⊞</span>
|
||||||
</h3>
|
<h3 class="home-quick__title">
|
||||||
|
快捷入口
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="home-quick__list">
|
<div class="home-quick__list">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="entry in entries"
|
v-for="entry in entries"
|
||||||
:key="entry.label"
|
:key="`${entry.label}-${entry.id}`"
|
||||||
:to="entry.path"
|
:to="resolvePath(entry)"
|
||||||
class="home-quick__item"
|
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>
|
<span class="home-quick__label">{{ entry.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="home-quick__more link" type="button" @click="onMore">
|
||||||
|
更多应用 >
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<HomeAppDialog v-model:visible="dialogVisible" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-quick {
|
.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 {
|
&__title {
|
||||||
margin-bottom: @spacing-base;
|
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(row, space-between);
|
.flex(row, space-between, flex-start);
|
||||||
gap: @spacing-base;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(column, center);
|
.flex(column, flex-start, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: @spacing-base;
|
transition: transform 0.2s;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: @shadow-base;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: @shadow-lg;
|
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__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 {
|
&__label {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
color: @text-color;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
/** 展示顺序与设计稿一致:累积项目 / 进行中 / 已提交 */
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'pending', label: '待您处理' },
|
{ key: 'total', label: '累积项目' },
|
||||||
{ key: 'inProgress', label: '进行中' },
|
{ key: 'inProgress', label: '进行中' },
|
||||||
{ key: 'total', label: '累计项目' },
|
{ key: 'pending', label: '已提交' },
|
||||||
] as const
|
] as const
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,24 +23,23 @@ const items = [
|
|||||||
class="home-stats__item"
|
class="home-stats__item"
|
||||||
>
|
>
|
||||||
<span class="home-stats__value">{{ stats[item.key] }}</span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-stats {
|
.home-stats {
|
||||||
.flex(row, space-between);
|
.flex(row, flex-end);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
.flex(column, center);
|
.flex(column, center, center);
|
||||||
flex: 1;
|
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
padding: @spacing-base @spacing-lg;
|
min-width: 84px;
|
||||||
|
padding: @spacing-base;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
box-shadow: @shadow-base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
@@ -49,7 +49,8 @@ const items = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,57 +18,42 @@ const greeting = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="home-welcome">
|
<div class="home-welcome">
|
||||||
<div class="home-welcome__info">
|
<h2 class="home-welcome__greeting">
|
||||||
<h2 class="home-welcome__greeting">
|
{{ greeting }}
|
||||||
{{ greeting }}
|
<span class="home-welcome__wave">👋</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="home-welcome__summary text-secondary">
|
<p class="home-welcome__summary">
|
||||||
{{ summary }}
|
{{ summary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-welcome__brand">
|
|
||||||
<!-- TODO(蓝湖): 替换头像切图 -->
|
|
||||||
<span class="home-welcome__avatar">王</span>
|
|
||||||
<span class="home-welcome__appName">虫子智造</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.home-welcome {
|
.home-welcome {
|
||||||
.flex(row, space-between);
|
.flex(column, center, flex-start);
|
||||||
padding: @spacing-lg @spacing-xl;
|
gap: @spacing-sm;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: linear-gradient(120deg, @primary-color, @primary-hover);
|
|
||||||
color: @white;
|
|
||||||
|
|
||||||
&__greeting {
|
&__greeting {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xxl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: @white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__summary {
|
&__wave {
|
||||||
margin-top: @spacing-xs;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__brand {
|
|
||||||
.flex(row, center);
|
.flex(row, center);
|
||||||
gap: @spacing-sm;
|
width: 32px;
|
||||||
}
|
height: 32px;
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
.flex(row, center);
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__appName {
|
&__summary {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-lg;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { AppButton } from '@/components'
|
||||||
// 业务详情页:数据只经 useHomeStore,不直接 import api
|
// 业务详情页:数据只经 useHomeStore,不直接 import api
|
||||||
import { useHomeStore } from '@/stores/modules/home'
|
import { useHomeStore } from '@/stores/modules/home'
|
||||||
import type { BusinessType } from '@/types/api'
|
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 route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const homeStore = useHomeStore()
|
const homeStore = useHomeStore()
|
||||||
|
|
||||||
const line = computed(() => homeStore.currentBusiness)
|
const line = computed(() => homeStore.currentBusiness)
|
||||||
@@ -47,10 +50,15 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<HomeServiceTabs
|
<HomeServiceTypes :service-types="line.serviceTypes" />
|
||||||
:service-types="line.serviceTypes"
|
|
||||||
:service-content="line.serviceContent"
|
<HomeServiceContents :contents="line.contents" />
|
||||||
/>
|
|
||||||
|
<div class="business-detail__cta">
|
||||||
|
<AppButton type="primary" size="lg" @click="router.push('/demand')">
|
||||||
|
立即提报
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-else class="text-secondary">
|
<p v-else class="text-secondary">
|
||||||
@@ -104,5 +112,13 @@ onMounted(load)
|
|||||||
&__slogan {
|
&__slogan {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__cta {
|
||||||
|
padding-top: @spacing-base;
|
||||||
|
|
||||||
|
.app-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export { default } from './index.vue'
|
export { default } from './index.vue'
|
||||||
|
export { default as HomeBanner } from './cpns/HomeBanner.vue'
|
||||||
export { default as HomeWelcome } from './cpns/HomeWelcome.vue'
|
export { default as HomeWelcome } from './cpns/HomeWelcome.vue'
|
||||||
export { default as HomeStats } from './cpns/HomeStats.vue'
|
export { default as HomeStats } from './cpns/HomeStats.vue'
|
||||||
export { default as HomeQuickEntries } from './cpns/HomeQuickEntries.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 HomeBusinessCards } from './cpns/HomeBusinessCards.vue'
|
||||||
export { default as HomeCaseShowcase } from './cpns/HomeCaseShowcase.vue'
|
export { default as HomeCaseShowcase } from './cpns/HomeCaseShowcase.vue'
|
||||||
export { default as HomeServiceTabs } from './cpns/HomeServiceTabs.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'
|
||||||
|
|||||||
@@ -4,23 +4,18 @@ import { storeToRefs } from 'pinia'
|
|||||||
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
||||||
import { useHomeStore } from '@/stores/modules/home'
|
import { useHomeStore } from '@/stores/modules/home'
|
||||||
import type { BusinessType } from '@/types/api'
|
import type { BusinessType } from '@/types/api'
|
||||||
|
import HomeBanner from './cpns/HomeBanner.vue'
|
||||||
import HomeWelcome from './cpns/HomeWelcome.vue'
|
import HomeWelcome from './cpns/HomeWelcome.vue'
|
||||||
import HomeStats from './cpns/HomeStats.vue'
|
import HomeStats from './cpns/HomeStats.vue'
|
||||||
import HomeQuickEntries from './cpns/HomeQuickEntries.vue'
|
import HomeQuickEntries from './cpns/HomeQuickEntries.vue'
|
||||||
import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
|
import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
|
||||||
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
|
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
|
||||||
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
|
|
||||||
|
|
||||||
const homeStore = useHomeStore()
|
const homeStore = useHomeStore()
|
||||||
const { stats, businessLines, featuredCases } = storeToRefs(homeStore)
|
const { stats, banners, businessLines, featuredCases } = storeToRefs(homeStore)
|
||||||
|
|
||||||
const todaySummary = computed(() => `今天有${stats.value.inProgress}个项目正在进行`)
|
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 {
|
function onCategoryChange(category: BusinessType | 'all'): void {
|
||||||
void homeStore.fetchFeaturedCases(category)
|
void homeStore.fetchFeaturedCases(category)
|
||||||
}
|
}
|
||||||
@@ -32,19 +27,28 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<HomeWelcome :summary="todaySummary" />
|
<div class="container">
|
||||||
<HomeStats :stats="stats" />
|
<HomeBanner :banners="banners" />
|
||||||
<HomeQuickEntries />
|
</div>
|
||||||
<HomeBusinessCards :lines="businessLines" />
|
|
||||||
<HomeCaseShowcase
|
<div class="container home__dashboard">
|
||||||
:cases="featuredCases"
|
<section class="home__dashboard-main">
|
||||||
@category-change="onCategoryChange"
|
<HomeWelcome :summary="todaySummary" />
|
||||||
/>
|
<HomeStats :stats="stats" />
|
||||||
<HomeServiceTabs
|
</section>
|
||||||
v-if="serviceLine"
|
<HomeQuickEntries />
|
||||||
:service-types="serviceLine.serviceTypes"
|
</div>
|
||||||
:service-content="serviceLine.serviceContent"
|
|
||||||
/>
|
<div class="container">
|
||||||
|
<HomeBusinessCards :lines="businessLines" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<HomeCaseShowcase
|
||||||
|
:cases="featuredCases"
|
||||||
|
@category-change="onCategoryChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -54,5 +58,20 @@ onMounted(() => {
|
|||||||
gap: @spacing-xl;
|
gap: @spacing-xl;
|
||||||
padding-top: @spacing-xl;
|
padding-top: @spacing-xl;
|
||||||
padding-bottom: @spacing-xxl;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const activeTab = ref<LoginTab>('account')
|
const activeTab = ref<LoginTab>('account')
|
||||||
|
|
||||||
const account = ref('')
|
// 演示/联调默认账号:登录页打开即自动预填,可直接点登录(生产环境请清空)
|
||||||
const password = ref('')
|
const account = ref('admin')
|
||||||
|
const password = ref('admin123')
|
||||||
const phone = ref('')
|
const phone = ref('')
|
||||||
const code = ref('')
|
const code = ref('')
|
||||||
const agree = ref(false)
|
const agree = ref(false)
|
||||||
|
|||||||
@@ -2,45 +2,30 @@
|
|||||||
import { onBeforeUnmount, ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { PasswordStrength, SmsCodeInput } from '@/components'
|
import { PasswordStrength } from '@/components'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
import { isPhone, isValidPassword } from '@/utils/validate'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const phone = ref('')
|
const username = ref('')
|
||||||
const code = ref('')
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const agree = ref(false)
|
const agree = ref(false)
|
||||||
|
|
||||||
let redirectTimer: number | null = null
|
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> {
|
async function onRegister(): Promise<void> {
|
||||||
if (!isPhone(phone.value)) {
|
if (!username.value.trim()) {
|
||||||
ElMessage.warning('手机号格式不正确')
|
ElMessage.warning('请输入账号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!/^\d{6}$/.test(code.value)) {
|
if (username.value.trim().length < 2 || username.value.trim().length > 30) {
|
||||||
ElMessage.warning('请输入6位验证码')
|
ElMessage.warning('账号长度应为2-30位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isValidPassword(password.value)) {
|
if (password.value.length < 5 || password.value.length > 30) {
|
||||||
ElMessage.warning('密码长度应为6-20位')
|
ElMessage.warning('密码长度应为5-30位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
@@ -52,11 +37,9 @@ async function onRegister(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ok = await userStore.register({
|
const ok = await userStore.register({
|
||||||
phone: phone.value,
|
username: username.value.trim(),
|
||||||
code: code.value,
|
|
||||||
password: password.value,
|
password: password.value,
|
||||||
confirmPassword: confirmPassword.value,
|
confirmPassword: confirmPassword.value,
|
||||||
agree: agree.value,
|
|
||||||
})
|
})
|
||||||
if (ok) {
|
if (ok) {
|
||||||
redirectTimer = window.setTimeout(() => {
|
redirectTimer = window.setTimeout(() => {
|
||||||
@@ -94,20 +77,19 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<form class="auth-page__form" @submit.prevent="onRegister">
|
<form class="auth-page__form" @submit.prevent="onRegister">
|
||||||
<input
|
<input
|
||||||
v-model="phone"
|
v-model="username"
|
||||||
class="auth-page__input"
|
class="auth-page__input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入账号(2-30位)"
|
||||||
maxlength="11"
|
maxlength="30"
|
||||||
autocomplete="tel"
|
autocomplete="username"
|
||||||
>
|
>
|
||||||
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
|
|
||||||
<div class="auth-page__pwd-wrap">
|
<div class="auth-page__pwd-wrap">
|
||||||
<input
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
class="auth-page__input"
|
class="auth-page__input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="设置密码(6-20位)"
|
placeholder="设置密码(5-30位)"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
>
|
>
|
||||||
<PasswordStrength :password="password" />
|
<PasswordStrength :password="password" />
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="after-sales">
|
<div class="container after-sales">
|
||||||
<nav class="after-sales__breadcrumb text-secondary">
|
<nav class="after-sales__breadcrumb text-secondary">
|
||||||
<RouterLink to="/project" class="link">
|
<RouterLink to="/project" class="link">
|
||||||
项目中心
|
项目中心
|
||||||
@@ -247,7 +247,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.after-sales {
|
.after-sales {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding-top: @spacing-lg;
|
padding-top: @spacing-lg;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
@@ -257,12 +257,12 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__section {
|
&__section {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-lg;
|
gap: @spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__files {
|
&__files {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-after-sales {
|
.project-after-sales {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding: @spacing-lg @spacing-xl;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
@@ -77,7 +77,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
|
|||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
padding: @spacing-base;
|
padding: @spacing-base;
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-files {
|
.project-files {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding: @spacing-lg @spacing-xl;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
@@ -78,7 +78,7 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { FilterPanel, type FilterChip } from '@/components'
|
import { PROJECT_STATUS_LABEL, PROJECT_STATUS_OPTIONS, PROJECT_TIME_OPTIONS } from '@/utils/projectMeta'
|
||||||
import { formatBusinessType } from '@/utils/format'
|
import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
|
||||||
import type { BusinessType } from '@/types/api'
|
|
||||||
import type { ProjectQuery, ProjectStatus } from '@/types/business/project'
|
/**
|
||||||
|
* 项目中心左侧筛选栏(按项目中心设计稿还原)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 业务类型的**值域、文案、件数全部来自 store**(`typeCounts` ← `GET /web/requirementPool/getFilterConditions`
|
||||||
|
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
|
||||||
|
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
|
||||||
|
*
|
||||||
|
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
|
||||||
|
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
|
||||||
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: ProjectQuery
|
query: ProjectQuery
|
||||||
|
/** 当前筛选命中的项目数(确定筛选按钮上的「N个结果」) */
|
||||||
total: number
|
total: number
|
||||||
|
typeCounts: ProjectTypeCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chip {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -18,48 +34,31 @@ const emit = defineEmits<{
|
|||||||
reset: []
|
reset: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const businessTypeOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
|
/** 自选时间区间是否展开(设计稿只给了按钮态,展开后是两个原生日期输入) */
|
||||||
{ label: '全部', value: 'all' },
|
const rangeOpen = ref(false)
|
||||||
{ label: '互联网软件', value: 'internet' },
|
|
||||||
{ label: '硬件电路', value: 'hardware' },
|
|
||||||
{ label: '物联网全案', value: 'iot' },
|
|
||||||
{ label: '生产制造', value: 'manufacturing' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions: Array<{ label: string; value: ProjectStatus | 'all' }> = [
|
const rangeLabel = computed(() => {
|
||||||
{ label: '全部', value: 'all' },
|
const { startDate, endDate } = props.query
|
||||||
{ label: '待立项', value: '待立项' },
|
if (!startDate && !endDate) return '开始时间—结束时间'
|
||||||
{ label: '进行中', value: '进行中' },
|
return `${startDate || '不限'} — ${endDate || '不限'}`
|
||||||
{ 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 chips = computed<FilterChip[]>(() => {
|
const chips = computed<Chip[]>(() => {
|
||||||
const list: FilterChip[] = []
|
const list: Chip[] = []
|
||||||
if (props.query.businessType !== 'all') {
|
if (props.query.businessType) {
|
||||||
list.push({ key: 'businessType', label: formatBusinessType(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') {
|
if (props.query.status) {
|
||||||
list.push({ key: 'status', label: props.query.status })
|
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
|
||||||
}
|
}
|
||||||
if (props.query.timeShortcut !== 'all') {
|
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 })
|
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
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,125 +66,386 @@ function patch(partial: Partial<ProjectQuery>): void {
|
|||||||
emit('update:query', { ...props.query, ...partial })
|
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 {
|
function onRemoveChip(key: string): void {
|
||||||
if (key === 'businessType') patch({ businessType: 'all' })
|
if (key === 'businessType') patch({ businessType: '' })
|
||||||
else if (key === 'status') patch({ status: 'all' })
|
else if (key === 'status') patch({ status: '' })
|
||||||
else if (key === 'time') patch({ timeShortcut: 'all' })
|
else if (key === 'time') patch({ timeShortcut: 'all' })
|
||||||
|
else if (key === 'range') patch({ startDate: '', endDate: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClearChips(): void {
|
function onClearChips(): void {
|
||||||
emit('update:query', {
|
emit('update:query', {
|
||||||
...props.query,
|
...props.query,
|
||||||
businessType: 'all',
|
businessType: '',
|
||||||
status: 'all',
|
status: '',
|
||||||
timeShortcut: 'all',
|
timeShortcut: 'all',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FilterPanel
|
<aside class="project-filter">
|
||||||
v-model:keyword="keyword"
|
<h2 class="project-filter__title">
|
||||||
:chips="chips"
|
筛选条件
|
||||||
:show-result-count="true"
|
</h2>
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- 项目状态(单选) -->
|
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
|
||||||
<div class="project-filter__group">
|
<section class="project-filter__group">
|
||||||
<span class="project-filter__group-title">项目状态</span>
|
<h3 class="project-filter__group-title">
|
||||||
<div class="project-filter__options">
|
业务类型
|
||||||
|
</h3>
|
||||||
|
<ul class="project-filter__list">
|
||||||
|
<li v-for="item in typeCounts" :key="item.value">
|
||||||
|
<button
|
||||||
|
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
|
<button
|
||||||
v-for="option in statusOptions"
|
v-for="option in PROJECT_STATUS_OPTIONS"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="project-filter__option"
|
type="button"
|
||||||
|
class="project-filter__pill"
|
||||||
:class="{ 'is-active': query.status === option.value }"
|
:class="{ 'is-active': query.status === option.value }"
|
||||||
@click="patch({ status: option.value })"
|
@click="patch({ status: option.value })"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- 提交时间(快捷区间) -->
|
<!-- 提交时间:快捷区间 + 自选区间 -->
|
||||||
<div class="project-filter__group">
|
<section class="project-filter__group">
|
||||||
<span class="project-filter__group-title">提交时间</span>
|
<h3 class="project-filter__group-title">
|
||||||
<div class="project-filter__options">
|
提交时间
|
||||||
|
</h3>
|
||||||
|
<div class="project-filter__grid">
|
||||||
<button
|
<button
|
||||||
v-for="shortcut in timeShortcuts"
|
v-for="option in PROJECT_TIME_OPTIONS"
|
||||||
:key="shortcut.value"
|
:key="option.value"
|
||||||
class="project-filter__option"
|
type="button"
|
||||||
:class="{ 'is-active': query.timeShortcut === shortcut.value }"
|
class="project-filter__pill"
|
||||||
@click="patch({ timeShortcut: shortcut.value })"
|
: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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
</div>
|
||||||
</FilterPanel>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-filter {
|
.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 {
|
&__group {
|
||||||
.flex(row, flex-start);
|
margin-top: 18px;
|
||||||
gap: @spacing-base;
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__group-title {
|
&__group-title {
|
||||||
flex-shrink: 0;
|
margin-bottom: 12px;
|
||||||
width: 70px;
|
font-size: @font-size-lg;
|
||||||
padding-top: 5px;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__options {
|
// ── 业务类型列表 ──
|
||||||
.flex(row, flex-start, center);
|
&__list {
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
gap: @spacing-sm;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__option {
|
&__row {
|
||||||
height: 28px;
|
.flex(row, flex-start, center);
|
||||||
padding: 0 @spacing-sm;
|
width: 100%;
|
||||||
border: 1px solid transparent;
|
height: 31px;
|
||||||
|
padding: 0 6px;
|
||||||
border-radius: @radius-sm;
|
border-radius: @radius-sm;
|
||||||
background: @bg-color;
|
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;
|
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 {
|
&:hover {
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border-color: @primary-color;
|
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;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-finance {
|
.project-finance {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding: @spacing-lg @spacing-xl;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
@@ -150,7 +150,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__plan {
|
&__plan {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
padding: @spacing-base;
|
padding: @spacing-base;
|
||||||
@@ -171,7 +171,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__plan-meta {
|
&__plan-meta {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__files {
|
&__files {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__file-info {
|
&__file-info {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
|
|||||||
'待立项': 'info',
|
'待立项': 'info',
|
||||||
'进行中': 'primary',
|
'进行中': 'primary',
|
||||||
'待验收': 'warning',
|
'待验收': 'warning',
|
||||||
|
'待内部验收': 'warning',
|
||||||
'已完成': 'success',
|
'已完成': 'success',
|
||||||
'售后中': 'warning',
|
'售后中': 'warning',
|
||||||
'已取消': 'info',
|
'已取消': 'info',
|
||||||
@@ -70,7 +71,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__meta-item {
|
&__meta-item {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-xs;
|
gap: @spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ defineProps<Props>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,58 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { StatusTag } from '@/components'
|
import { computed } from 'vue'
|
||||||
import { formatBusinessType } from '@/utils/format'
|
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
||||||
import type { Project, ProjectStatus } from '@/types/business/project'
|
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 {
|
interface Props {
|
||||||
projects: Project[]
|
projects: RdProjectVo[]
|
||||||
|
/** 业务类型件数列表(含「全部」);用它建 id → 中文 的映射 */
|
||||||
|
typeCounts: ProjectTypeCount[]
|
||||||
|
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
|
||||||
|
filtered: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
view: [id: string]
|
view: [id: string]
|
||||||
afterSales: [id: string]
|
afterSales: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
/** businessTypeId → 业务类型中文 */
|
||||||
'待立项': 'info',
|
const typeLabels = computed<Record<string, string>>(() => {
|
||||||
'进行中': 'primary',
|
const map: Record<string, string> = {}
|
||||||
'待验收': 'warning',
|
for (const item of props.typeCounts) {
|
||||||
'已完成': 'success',
|
if (item.value !== 'all') map[item.value] = item.label
|
||||||
'售后中': 'warning',
|
}
|
||||||
'已取消': 'info',
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -35,7 +67,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
|
|||||||
<th>业务类型</th>
|
<th>业务类型</th>
|
||||||
<th>进度</th>
|
<th>进度</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -45,37 +77,30 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
|
|||||||
<td class="project-table__name" :title="item.projectName">
|
<td class="project-table__name" :title="item.projectName">
|
||||||
{{ item.projectName }}
|
{{ item.projectName }}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatBusinessType(item.businessType) }}</td>
|
<td>{{ typeLabel(item.businessType) }}</td>
|
||||||
<td>
|
<td>{{ progressLabel(item.progress) }}</td>
|
||||||
<div class="project-table__progress">
|
<td>{{ statusLabel(item.status) }}</td>
|
||||||
<div class="project-table__track">
|
<td class="project-table__ops">
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
v-if="item.status === '已完成'"
|
v-if="item.status === 'COMPLETED'"
|
||||||
class="project-table__btn project-table__btn--accent"
|
type="button"
|
||||||
|
class="project-table__link"
|
||||||
@click="emit('afterSales', item.id)"
|
@click="emit('afterSales', item.id)"
|
||||||
>
|
>
|
||||||
申请售后
|
申请售后
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="project-table__link"
|
||||||
|
@click="emit('view', item.id)"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="projects.length === 0">
|
<tr v-if="projects.length === 0">
|
||||||
<td colspan="7" class="project-table__empty text-secondary">
|
<td colspan="7" class="project-table__empty">
|
||||||
暂无符合条件的项目
|
{{ filtered ? '暂无符合条件的项目' : '暂无项目' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -86,98 +111,71 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
|
|||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-table {
|
.project-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: @shadow-base;
|
|
||||||
.scrollbar();
|
.scrollbar();
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: @spacing-base @spacing-base;
|
border: 1px solid @project-table-line;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
border-bottom: 1px solid @border-color;
|
vertical-align: middle;
|
||||||
white-space: nowrap;
|
// 设计稿表格正文实测 12px(比左栏正文小一号),沿用 14px 会把操作列挤到换行
|
||||||
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: @bg-color-light;
|
height: 44px;
|
||||||
|
background: @project-table-head-bg;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
color: @text-color;
|
height: 76px;
|
||||||
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:last-child td {
|
// 列宽按设计稿实测比例(1002 总宽 → 61/294/218/107/144/90/88)
|
||||||
border-bottom: none;
|
th:nth-child(1) { width: 6.09%; }
|
||||||
}
|
th:nth-child(2) { width: 29.34%; }
|
||||||
|
th:nth-child(3) { width: 21.76%; }
|
||||||
tbody tr:hover td {
|
th:nth-child(4) { width: 10.68%; }
|
||||||
background: @bg-color-light;
|
th:nth-child(5) { width: 14.37%; }
|
||||||
}
|
th:nth-child(6) { width: 8.98%; }
|
||||||
|
th:nth-child(7) { width: 8.78%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
max-width: 220px;
|
padding: 0 @spacing-sm;
|
||||||
color: @heading-color;
|
|
||||||
.ellipsis();
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__progress {
|
&__ops {
|
||||||
.flex(row, center);
|
.flex(row, center, center);
|
||||||
gap: @spacing-sm;
|
gap: 10px;
|
||||||
min-width: 140px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__track {
|
&__link {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
height: 6px;
|
width: auto;
|
||||||
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;
|
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
white-space: nowrap;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--accent {
|
|
||||||
color: @warning-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
padding: @spacing-xl 0;
|
color: @text-color-secondary;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ defineProps<Props>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
.flex(column, flex-start);
|
.flex(column, flex-start, stretch);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,20 @@ function load(): void {
|
|||||||
void projectStore.fetchDetail(projectId.value)
|
void projectStore.fetchDetail(projectId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
|
||||||
|
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
|
||||||
|
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
|
||||||
|
* 混入前端自造条目会让刷新前后不一致。
|
||||||
|
*/
|
||||||
function onUpload(file: File): void {
|
function onUpload(file: File): void {
|
||||||
void projectStore.uploadFile(file).then(() => {
|
void file
|
||||||
ElMessage.success('文件已上传')
|
ElMessage.info('附件上传接口待接入')
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeleteFile(index: number): void {
|
function onDeleteFile(index: number): void {
|
||||||
void projectStore.deleteFile(index).then(() => {
|
void index
|
||||||
ElMessage.success('文件已删除')
|
ElMessage.info('附件删除接口待接入')
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPreviewFile(): void {
|
function onPreviewFile(): void {
|
||||||
@@ -87,7 +91,7 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="project-detail">
|
<div class="container project-detail">
|
||||||
<nav class="project-detail__breadcrumb text-secondary">
|
<nav class="project-detail__breadcrumb text-secondary">
|
||||||
<RouterLink to="/project" class="link">
|
<RouterLink to="/project" class="link">
|
||||||
项目中心
|
项目中心
|
||||||
@@ -166,7 +170,9 @@ onMounted(load)
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-detail {
|
.project-detail {
|
||||||
.flex(column, flex-start);
|
// ⚠️ mixin 签名是 .flex(@direction; @justify; @align) —— 第二参是 justify、**第三参才是 align**。
|
||||||
|
// 原来只传两参 ⇒ align 取默认的 center ⇒ 纵向容器里所有卡片被压成内容宽度、水平居中(380px 窄条)。
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-base;
|
||||||
padding-top: @spacing-lg;
|
padding-top: @spacing-lg;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export { default } from './index.vue'
|
export { default } from './index.vue'
|
||||||
export { default as ProjectFilter } from './cpns/ProjectFilter.vue'
|
export { default as ProjectFilter } from './cpns/ProjectFilter.vue'
|
||||||
export { default as ProjectTable } from './cpns/ProjectTable.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 ProjectInfoCard } from './cpns/ProjectInfoCard.vue'
|
||||||
export { default as ProjectTeam } from './cpns/ProjectTeam.vue'
|
export { default as ProjectTeam } from './cpns/ProjectTeam.vue'
|
||||||
export { default as ProjectMilestones } from './cpns/ProjectMilestones.vue'
|
export { default as ProjectMilestones } from './cpns/ProjectMilestones.vue'
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { PaginationBar } from '@/components'
|
|
||||||
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
||||||
import { useProjectStore } from '@/stores/modules/project'
|
import { useProjectStore } from '@/stores/modules/project'
|
||||||
import type { ProjectQuery } from '@/types/business/project'
|
import type { ProjectQuery } from '@/types/business/project'
|
||||||
import ProjectFilter from './cpns/ProjectFilter.vue'
|
import ProjectFilter from './cpns/ProjectFilter.vue'
|
||||||
|
import ProjectPager from './cpns/ProjectPager.vue'
|
||||||
import ProjectTable from './cpns/ProjectTable.vue'
|
import ProjectTable from './cpns/ProjectTable.vue'
|
||||||
|
import ProjectToolbar from './cpns/ProjectToolbar.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const projectStore = useProjectStore()
|
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 {
|
function onUpdateQuery(next: ProjectQuery): void {
|
||||||
projectStore.setQuery(next)
|
projectStore.setQuery(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onUpdateKeyword(keyword: string): void {
|
||||||
|
projectStore.setQuery({ ...query.value, keyword })
|
||||||
|
}
|
||||||
|
|
||||||
function onSearch(): void {
|
function onSearch(): void {
|
||||||
void projectStore.search()
|
void projectStore.search()
|
||||||
}
|
}
|
||||||
@@ -25,8 +40,8 @@ function onReset(): void {
|
|||||||
void projectStore.resetQuery()
|
void projectStore.resetQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageChange(page: number): void {
|
function onPageChange(pageNum: number): void {
|
||||||
void projectStore.changePage(page)
|
void projectStore.changePage(pageNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onView(id: string): void {
|
function onView(id: string): void {
|
||||||
@@ -37,61 +52,101 @@ function onAfterSales(id: string): void {
|
|||||||
void router.push(`/project/${id}/after-sales`)
|
void router.push(`/project/${id}/after-sales`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进页面并发两件事:列表(/list)+ 业务类型筛选条件(/getFilterConditions,幂等只拉一次)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void projectStore.fetchList()
|
void projectStore.ensureFilterConditions()
|
||||||
|
void projectStore.load()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="project">
|
<div class="project">
|
||||||
<header class="project__head">
|
<!-- 标题区(白底通栏,与案例库 hero 同一做法) -->
|
||||||
<h1 class="project__title">
|
<div class="project__head">
|
||||||
项目中心
|
<div class="container">
|
||||||
</h1>
|
<h1 class="project__title">
|
||||||
<p class="project__slogan text-secondary">
|
项目中心
|
||||||
查看和管理您的所有项目,实时跟踪项目进度
|
</h1>
|
||||||
</p>
|
<p class="project__slogan">
|
||||||
</header>
|
查看和管理您的所有项目,实时跟踪项目进度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProjectFilter
|
<div class="container">
|
||||||
:query="query"
|
<div class="project__body">
|
||||||
:total="total"
|
<ProjectFilter
|
||||||
@update:query="onUpdateQuery"
|
:query="query"
|
||||||
@search="onSearch"
|
:total="total"
|
||||||
@reset="onReset"
|
:type-counts="typeCounts"
|
||||||
/>
|
@update:query="onUpdateQuery"
|
||||||
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
<ProjectTable
|
<section class="project__main">
|
||||||
:projects="list"
|
<ProjectToolbar
|
||||||
@view="onView"
|
:keyword="query.keyword"
|
||||||
@after-sales="onAfterSales"
|
@update:keyword="onUpdateKeyword"
|
||||||
/>
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
<PaginationBar
|
<ProjectTable
|
||||||
:page="query.page"
|
:projects="list"
|
||||||
:page-size="query.pageSize"
|
:type-counts="typeCounts"
|
||||||
:total="total"
|
:filtered="filtered"
|
||||||
@page-change="onPageChange"
|
@view="onView"
|
||||||
/>
|
@after-sales="onAfterSales"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectPager
|
||||||
|
:page="query.pageNum"
|
||||||
|
:page-size="query.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project {
|
.project {
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
padding-top: @spacing-xl;
|
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
padding: 16px 0 32px;
|
||||||
|
background: @white;
|
||||||
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-xxl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slogan {
|
&__slogan {
|
||||||
margin-top: @spacing-xs;
|
margin-top: 14px;
|
||||||
font-size: @font-size-base;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user