first1
This commit is contained in:
19
src/api/user.ts
Normal file
19
src/api/user.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// 用户资料接口(真实后端绑定)
|
||||
// 后端:org.dromara.system.controller.system.SysUserController#profile → /system/user/profile
|
||||
// 鉴权:需登录态
|
||||
import { request } from '@/utils/request'
|
||||
import type { UserProfileVo } from '@/types/business/user'
|
||||
|
||||
/**
|
||||
* 当前登录用户资料(需求提报页预填联系人姓名 / 电话)。
|
||||
*
|
||||
* <p>⚠️ 后端返回的是 `R<Map>`,用户对象嵌在 **`data.user`** 里(同级还有 roles / posts),
|
||||
* 不是平铺在 data 上——这里在 api 层解包,调用方拿到的是用户对象本身。</p>
|
||||
*/
|
||||
export async function getUserProfile(): Promise<UserProfileVo> {
|
||||
const data = await request<{ user: UserProfileVo }>({
|
||||
url: '/system/user/profile',
|
||||
method: 'get',
|
||||
})
|
||||
return data.user
|
||||
}
|
||||
23
src/types/business/auth.d.ts
vendored
Normal file
23
src/types/business/auth.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// 账号密码登录/注册真实接口类型(对接 vermis-maker /auth/*)
|
||||
// 归位于 types/business/auth.d.ts,与 api/auth.ts 对应
|
||||
|
||||
/** 登录成功返回,对应后端 LoginVo(字段名以 @JsonProperty 为准) */
|
||||
export interface LoginResult {
|
||||
/** 授权令牌(后端字段 access_token) */
|
||||
access_token: string
|
||||
/** 刷新令牌 */
|
||||
refresh_token?: string
|
||||
/** access_token 有效期(秒) */
|
||||
expire_in?: number
|
||||
/** refresh_token 有效期(秒) */
|
||||
refresh_expire_in?: number
|
||||
/** 应用 id */
|
||||
client_id?: string
|
||||
/** 令牌权限 */
|
||||
scope?: string
|
||||
/** 用户 openid */
|
||||
openid?: string
|
||||
}
|
||||
|
||||
/** 注册返回:后端 R<Void>,无 data */
|
||||
export type RegisterResult = void
|
||||
18
src/utils/asset.ts
Normal file
18
src/utils/asset.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// 资源地址工具(后端多个接口会下发「OSS 文件标识」而不是完整地址)
|
||||
//
|
||||
// 实测:案例 ProjectCaseVo.coverUrl、项目提报 ProjectSubmissionVo.coverUrl/logoUrl
|
||||
// 都可能是 OSS 主键(如 2099753406859292674)。直接当 <img src> 用会请求到当前站点的
|
||||
// 错误路径上(表现为 404 / 空白),故统一在这里收口判断。
|
||||
|
||||
/**
|
||||
* 仅当值本身是可直接访问的地址时才返回它,否则返回空串。
|
||||
*
|
||||
* <p>可接受:`http(s)://…`、协议相对 `//…`、站内绝对路径 `/…`。
|
||||
* OSS 文件标识(纯数字)、相对路径(`a/b.png`)一律判为不可直接使用。</p>
|
||||
*/
|
||||
export function toAssetUrl(raw: string | null | undefined): string {
|
||||
const value = (raw ?? '').trim()
|
||||
if (!value) return ''
|
||||
const isUrl = /^(https?:)?\/\//i.test(value) || value.startsWith('/')
|
||||
return isUrl ? value : ''
|
||||
}
|
||||
70
src/utils/bizTypeMeta.ts
Normal file
70
src/utils/bizTypeMeta.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// 业务类型卡片展示元信息(图标 + 一句话描述)
|
||||
//
|
||||
// 值域来源:项目提报列表 GET /web/projectSubmission/list(见 api/demand.ts),字段对应关系:
|
||||
// value ← businessType(类型名,也是提交时下发给后端的值)
|
||||
// label ← title
|
||||
// desc ← subtitle
|
||||
// icon ← logoUrl(实测存的是 OSS 文件标识,换链后才是图片地址)
|
||||
//
|
||||
// ⚠️ 后端这两个展示字段目前是半成品:subtitle 实测常与类型名重复(title/subtitle 都是
|
||||
// 「硬件电路」),logoUrl 存的是 OSS 主键而非地址。所以本文件保留「按类型名映射 desc/icon」
|
||||
// 的前端兜底,只在后端字段不可用时生效。后端把 subtitle / logo 填好后,BIZ_META 可整表删掉。
|
||||
import type { BizIconKey, BizTypeOption, SubmissionBizTypeVo } from '@/types/business/demand'
|
||||
import { toAssetUrl } from '@/utils/asset'
|
||||
|
||||
interface BizMeta {
|
||||
desc: string
|
||||
icon: BizIconKey
|
||||
}
|
||||
|
||||
/** 按业务类型名索引的展示元信息(前端兜底,后端字段缺失时使用) */
|
||||
const BIZ_META: Record<string, BizMeta> = {
|
||||
互联网软件: { desc: 'Web/小程序/APP/二次开发/桌面工具等', icon: 'code' },
|
||||
硬件电路: { desc: '原理图设计/PCB设计/电气效验等', icon: 'chip' },
|
||||
物联网全案: { desc: '软硬件开发+云端一体化/定制化开发等', icon: 'iot' },
|
||||
生产制造: { desc: 'PCB制版/SMT/整机装配/定制生产等', icon: 'factory' },
|
||||
// 「其他」只在此处作为**展示元信息**(描述/图标)备着:只有当接口真的下发一条
|
||||
// businessType = 「其他」时才用得上,本文件不会据此凭空生成选项。
|
||||
其他: { desc: '若没有对应的服务内容请选择该选项', icon: 'other' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 提报项 → 业务类型卡片选项。
|
||||
*
|
||||
* @param ossUrlById OSS 文件标识 → 可访问地址 的映射(换链结果;取不到时传空对象)
|
||||
* @returns 类型名为空的脏数据返回 null,由调用方过滤
|
||||
*/
|
||||
export function toBizTypeOption(
|
||||
vo: SubmissionBizTypeVo,
|
||||
ossUrlById: Record<string, string> = {},
|
||||
): BizTypeOption | null {
|
||||
const value = (vo.businessType ?? '').trim() || (vo.title ?? '').trim()
|
||||
if (!value) return null
|
||||
const label = (vo.title ?? '').trim() || value
|
||||
const meta = BIZ_META[value]
|
||||
const subtitle = (vo.subtitle ?? '').trim()
|
||||
// 后端 subtitle 常常直接填类型名本身,这种「描述」没有信息量 → 视为缺失,回退前端映射
|
||||
const desc = subtitle && subtitle !== value && subtitle !== label
|
||||
? subtitle
|
||||
: (meta?.desc ?? '')
|
||||
const logoRaw = (vo.logoUrl ?? '').trim()
|
||||
return {
|
||||
value,
|
||||
label,
|
||||
desc,
|
||||
icon: meta?.icon ?? 'other',
|
||||
// logoUrl 有时本就填的是地址,有时是 OSS 标识(需换链)
|
||||
iconUrl: toAssetUrl(logoRaw) || ossUrlById[logoRaw] || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 字典项 → 业务类型卡片选项(仅当提报表无可用数据、回退字典兜底时使用) */
|
||||
export function toBizTypeOptionFromDict(value: string, label: string): BizTypeOption {
|
||||
const meta = BIZ_META[value]
|
||||
return {
|
||||
value,
|
||||
label,
|
||||
desc: meta?.desc ?? '',
|
||||
icon: meta?.icon ?? 'other',
|
||||
}
|
||||
}
|
||||
52
src/utils/budgetMeta.ts
Normal file
52
src/utils/budgetMeta.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// 预算范围(字典 vm_budget_range)的展示/校验元信息
|
||||
//
|
||||
// 后端字典 `vm_budget_range` 共 5 项,最后一项(dictValue='4',label「手填」)语义为
|
||||
// 「不在预设档位里、由用户自己写一个金额」。设计上:
|
||||
// 前 4 项 = 普通胶囊按钮;最后一项 = **同排的第 5 个控件,本身就是一个输入框**
|
||||
// (占位文案直接用字典 label「手填」),不再是"点一下再额外弹一个输入框"。
|
||||
//
|
||||
// ⚠️ 手写文本没有独立的落库位置:`WorkOrderDraftBo.budgetCustom` 被注释掉、
|
||||
// `vm_work_order_draft` 表也没有 `budget_custom` 列(VO 上那个字段恒为 null),
|
||||
// 所以手写内容直接写进 `budgetRange` 本身。而提交落库的
|
||||
// `vm_project_requirement.budget_range` 是 **varchar(30)**,故输入框必须限长 30,
|
||||
// 否则 MySQL 严格模式下会报 1406(Data too long)导致提交失败。
|
||||
import type { DictOption } from '@/types/business/demand'
|
||||
|
||||
/** 字典里表示「自己填写」的那一项(后端 label 现为「手填」) */
|
||||
const MANUAL_PATTERN = /手填|手写|自定义/
|
||||
|
||||
/**
|
||||
* 手写内容最大长度。
|
||||
*
|
||||
* <p>= `vm_project_requirement.budget_range` 的列宽 varchar(30)。注意后端 BO 上
|
||||
* `@Size(max = 100)` 更宽松,真正的瓶颈是数据库列宽,故以 30 为准。</p>
|
||||
*/
|
||||
export const MANUAL_BUDGET_MAX = 30
|
||||
|
||||
/** 该字典项是否为「手写」项(决定它在页面上渲染成按钮还是输入框) */
|
||||
export function isManualBudget(label: string): boolean {
|
||||
return MANUAL_PATTERN.test(label)
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填草稿时区分「预设档位」与「手填内容」,并把预设档位**归一成 dictValue**。
|
||||
*
|
||||
* <p>判据:`budgetRange` 命中字典的 **dictValue 或 dictLabel** 即为预设档位;否则(非空)
|
||||
* 视为手填文本。后端只回传一个 budgetRange 字符串、没有模式标记(`budgetCustom` 恒为 null),
|
||||
* 只能这样反推。</p>
|
||||
*
|
||||
* <p>为什么要连 dictLabel 一起比、并且归一:字典 `vm_budget_range` 的 dictValue 是 '0'~'4',
|
||||
* 但库里的历史草稿存的是**文案**(如 `1万以下`、`10万以上`)。只比 dictValue 的话,这些历史档位
|
||||
* 会被误判成手填、串进输入框;而直接回填文案的话,页面按钮是按 dictValue 判选中的,
|
||||
* 又会出现"值在、按钮不亮"的空档。故统一转换成 dictValue(下次保存草稿即把历史文案收敛成码值)。</p>
|
||||
*/
|
||||
export function resolveDraftBudget(
|
||||
budgetRange: string | null | undefined,
|
||||
dict: DictOption[],
|
||||
): { manual: boolean, value: string } {
|
||||
const raw = (budgetRange ?? '').trim()
|
||||
if (!raw) return { manual: false, value: '' }
|
||||
const hit = dict.find((item) => item.dictValue === raw || item.dictLabel === raw)
|
||||
if (hit) return { manual: false, value: hit.dictValue }
|
||||
return { manual: true, value: raw }
|
||||
}
|
||||
89
src/utils/caseVo.ts
Normal file
89
src/utils/caseVo.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// 案例后端 VO → 页面实体映射(纯函数,无 Vue 依赖)
|
||||
//
|
||||
// 后端 ProjectCaseVo 与页面 Case 的差异(字段命名、业务类型取值、富文本、数值单位)
|
||||
// 统一在此对齐,供首页 store(案例库精选)与案例库 store(列表 / 详情)共用。
|
||||
|
||||
import type { Case, CaseAttachment, CaseSection, ProjectCaseVo } from '@/types/business/cases'
|
||||
import { toAssetUrl } from '@/utils/asset'
|
||||
import { toBusinessType } from '@/utils/format'
|
||||
|
||||
/** 规模展示值:后端下发「万」为单位的数值,页面按设计稿拼成「10万」 */
|
||||
function toScaleText(scale: number | null): string {
|
||||
return scale == null ? '' : `${scale}万`
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文区块:后端 catalogs 按顺序映射为「标题 + 富文本」。
|
||||
*
|
||||
* <p>标题由后端下发(vm_project_case_catalog.title),前端不再写死区块名;顺序沿用后端
|
||||
* 的 sort_order、id 升序。去掉标签后为空的内容会被过滤掉,避免渲染出只有标题的空白卡。
|
||||
* 列表 / 精选接口不下发 catalogs,故那些场景返回空数组。</p>
|
||||
*/
|
||||
function toSections(list: ProjectCaseVo['catalogs']): CaseSection[] {
|
||||
return (list ?? [])
|
||||
.map((item) => ({
|
||||
title: (item.title ?? '').trim(),
|
||||
content: (item.content ?? '').trim(),
|
||||
}))
|
||||
.filter((section) => section.content.replace(/<[^>]*>/g, '').replace(/ /gi, ' ').trim().length > 0)
|
||||
}
|
||||
|
||||
/** 附件:过滤掉没有访问地址的脏数据 */
|
||||
function toAttachments(list: ProjectCaseVo['attachments']): CaseAttachment[] {
|
||||
return (list ?? [])
|
||||
.filter((item) => (item.url ?? '').trim().length > 0)
|
||||
.map((item) => ({
|
||||
name: item.originalName ?? '附件',
|
||||
size: item.fileSize ?? '',
|
||||
url: (item.url ?? '').trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 提报主键 id → 业务类型名称 的字典(来源 GET /categories 的 projectCaseCounts) */
|
||||
export type CaseTypeNameMap = Record<string, string>
|
||||
|
||||
/**
|
||||
* 业务类型展示名解析。
|
||||
*
|
||||
* <p>优先级:后端 JOIN 出的 projectTypeName → 接口字典(id 反查名称)→ 原值。
|
||||
* 实测案例表 business_type 列存的是**提报主键 id**(如 "2099753433048526850")且
|
||||
* projectTypeName 为 null —— 若不查字典直接交给 toBusinessType,会全部归到「其他」。</p>
|
||||
*/
|
||||
function resolveCategoryName(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): string {
|
||||
return (
|
||||
vo.projectTypeName?.trim()
|
||||
|| typeNameById?.[String(vo.businessType)]
|
||||
|| vo.businessType
|
||||
|| ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端案例 VO → 页面案例实体。
|
||||
*
|
||||
* <p>列表接口不返回 catalogs / attachments,页面实体对应字段为空
|
||||
* (正文区块与附件仅详情接口下发)。</p>
|
||||
*
|
||||
* @param typeNameById 业务类型字典(id → 名称),取自案例库 store 的 /categories 结果
|
||||
*/
|
||||
export function toCase(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): Case {
|
||||
return {
|
||||
id: String(vo.id),
|
||||
title: vo.caseName ?? '',
|
||||
// 案例表 business_type 存提报主键 id,展示名需经字典翻译(见 resolveCategoryName)
|
||||
category: toBusinessType(resolveCategoryName(vo, typeNameById)),
|
||||
cover: toAssetUrl(vo.coverUrl),
|
||||
finishDate: vo.completedAt ?? '',
|
||||
teamSize: vo.serviceTeam ?? 0,
|
||||
scale: toScaleText(vo.scale),
|
||||
industry: vo.industry || vo.businessIndustry || '',
|
||||
duration: vo.projectCycle ?? '',
|
||||
clientType: vo.customerType ?? '',
|
||||
// 正文区块(标题 + 富文本)由后端 catalogs 下发,前端不再写死区块名
|
||||
sections: toSections(vo.catalogs),
|
||||
attachments: toAttachments(vo.attachments),
|
||||
video: '',
|
||||
isFavorite: vo.isFavorite === 1,
|
||||
favoriteCount: vo.favoriteCount ?? 0,
|
||||
}
|
||||
}
|
||||
94
src/utils/entryIcon.ts
Normal file
94
src/utils/entryIcon.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { EntryTone, PortalEntryCard, PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
|
||||
|
||||
/**
|
||||
* 门户入口的视图映射:后端 icon 字段 → 页面可直接渲染的图标 + 底色
|
||||
*
|
||||
* <p>后端 icon 是「图标名 / url」自由文本,可能为空、可能是图片地址、
|
||||
* 也可能是一个标识符(如 folder)。这里按优先级兜底,保证任何取值都能渲染出可视化图标。</p>
|
||||
*/
|
||||
|
||||
/** 图标底色(设计稿为五色循环),按序号轮转分配 */
|
||||
const TONE_CYCLE: EntryTone[] = ['purple', 'green', 'blue', 'amber', 'red']
|
||||
|
||||
/** 显示名 → emoji 兜底表(后端 icon 为空或非 emoji 时使用) */
|
||||
const NAME_ICON_MAP: Record<string, string> = {
|
||||
项目中心: '🗂️',
|
||||
需求中心: '🧾',
|
||||
需求池: '🧾',
|
||||
工单需求池: '📋',
|
||||
快捷提报: '➕',
|
||||
在线客服: '🎧',
|
||||
客服工作台: '💬',
|
||||
合同管理: '📑',
|
||||
合同台账: '📄',
|
||||
合同列表: '📁',
|
||||
合同档案: '📁',
|
||||
财务管理: '💰',
|
||||
财务台账: '💰',
|
||||
财务档案: '💰',
|
||||
案例库管理: '📁',
|
||||
案例库: '📁',
|
||||
个人中心: '👤',
|
||||
消息中心: '💬',
|
||||
账号安全: '🔒',
|
||||
意见反馈: '✉️',
|
||||
交易列表: '📊',
|
||||
交易明细: '📊',
|
||||
部署管理: '🚀',
|
||||
设备工作台: '🖥️',
|
||||
设备管理: '🖥️',
|
||||
资质列表: '📋',
|
||||
帮助中心: '❓',
|
||||
关于我们: 'ℹ️',
|
||||
}
|
||||
|
||||
/** 兜底图标 */
|
||||
const DEFAULT_ICON = '📦'
|
||||
|
||||
/** 是否图片地址 */
|
||||
function isImageUrl(value: string): boolean {
|
||||
return /^(https?:)?\/\//i.test(value) || value.startsWith('/')
|
||||
}
|
||||
|
||||
/** 是否可直接当作 emoji / 字符图标使用(非纯字母数字标识符) */
|
||||
function isGlyph(value: string): boolean {
|
||||
return !/^[a-zA-Z0-9_-]+$/.test(value)
|
||||
}
|
||||
|
||||
/** 解析单项图标与底色 */
|
||||
function resolveIcon(icon: string | null, name: string): { icon: string; isImage: boolean } {
|
||||
const raw = (icon ?? '').trim()
|
||||
if (raw) {
|
||||
if (isImageUrl(raw)) return { icon: raw, isImage: true }
|
||||
if (isGlyph(raw)) return { icon: raw, isImage: false }
|
||||
}
|
||||
return { icon: NAME_ICON_MAP[name] ?? DEFAULT_ICON, isImage: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端入口 → 页面卡片
|
||||
*
|
||||
* @param vo 后端入口
|
||||
* @param index 组内序号(决定底色轮转)
|
||||
*/
|
||||
export function toPortalEntryCard(vo: PortalEntryVo, index: number): PortalEntryCard | null {
|
||||
if (vo.id == null) return null
|
||||
const label = (vo.name ?? '').trim() || '未命名入口'
|
||||
const { icon, isImage } = resolveIcon(vo.icon, label)
|
||||
return {
|
||||
id: vo.id,
|
||||
label,
|
||||
route: (vo.route ?? '').trim(),
|
||||
groupType: (vo.groupType === '1' ? '1' : '0') as PortalEntryGroup,
|
||||
icon,
|
||||
isImage,
|
||||
tone: TONE_CYCLE[index % TONE_CYCLE.length],
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端入口列表 → 页面卡片列表(过滤无效项,保持后端下发顺序) */
|
||||
export function toPortalEntryCards(list: PortalEntryVo[]): PortalEntryCard[] {
|
||||
return list
|
||||
.map((vo, index) => toPortalEntryCard(vo, index))
|
||||
.filter((item): item is PortalEntryCard => item !== null)
|
||||
}
|
||||
48
src/utils/projectMeta.ts
Normal file
48
src/utils/projectMeta.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RdProjectStatus } from '@/types/business/project'
|
||||
|
||||
/**
|
||||
* 项目中心筛选栏的静态选项。
|
||||
*
|
||||
* <p>⚠️ 这里**只放后端当前没有下发的组**。业务类型的值域与件数必须走数据层
|
||||
* (`stores/modules/project.ts#typeCounts`,来自后端 `requirementStatistics.businessTypeCounts`),
|
||||
* 不要在这个文件里再定义一份,否则接口一改就错位。</p>
|
||||
*/
|
||||
|
||||
/**
|
||||
* 项目状态筛选项。
|
||||
*
|
||||
* <p>`value` 是后端列表状态的**码值**(`COALESCE(rd_project.status, project_requirement.status)`),
|
||||
* 空串表示「全部」(不出现在 querystring 里)。顺序与设计稿的 2 列网格一致。</p>
|
||||
*
|
||||
* <p>列表改成「需求表为主 + LEFT JOIN 项目表」后,未立项的需求状态回落为 `INITIATING`
|
||||
* ⇒ 设计稿画的「待立项」成了真实码值,故补进来(8 项 = 4 行 × 2 列,正好铺满)。</p>
|
||||
*/
|
||||
export const PROJECT_STATUS_OPTIONS: Array<{ label: string; value: RdProjectStatus | '' }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待立项', value: 'INITIATING' },
|
||||
{ label: '进行中', value: 'IN_PROGRESS' },
|
||||
{ label: '待验收', value: 'PENDING_ACCEPTANCE' },
|
||||
{ label: '待内部验收', value: 'PENDING_INTERNAL_ACCEPTANCE' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '售后中', value: 'AFTER_SALE' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
]
|
||||
|
||||
/** 码值 → 中文(表格状态列用;由上面那份选项派生,避免两处维护) */
|
||||
export const PROJECT_STATUS_LABEL: Record<string, string> = PROJECT_STATUS_OPTIONS
|
||||
.filter((option) => option.value !== '')
|
||||
.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.value] = option.label
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/** 提交时间快捷区间(后端未下发该组,前端换算成 beginTime/endTime 后下发) */
|
||||
export const PROJECT_TIME_OPTIONS: Array<{
|
||||
label: string
|
||||
value: 'all' | '3m' | '6m' | '1y'
|
||||
}> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '近3个月', value: '3m' },
|
||||
{ label: '近半年', value: '6m' },
|
||||
{ label: '近一年', value: '1y' },
|
||||
]
|
||||
174
src/utils/projectVo.ts
Normal file
174
src/utils/projectVo.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type {
|
||||
AfterSales,
|
||||
Milestone,
|
||||
Project,
|
||||
ProjectBill,
|
||||
ProjectStatus,
|
||||
RequirementAfterSale,
|
||||
RequirementAttachment,
|
||||
RequirementBillPlan,
|
||||
RequirementContract,
|
||||
RequirementDetailVo,
|
||||
RequirementInvoice,
|
||||
TeamMember,
|
||||
} from '@/types/business/project'
|
||||
import { toBusinessType } from '@/utils/format'
|
||||
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
||||
|
||||
/**
|
||||
* 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。
|
||||
*
|
||||
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
|
||||
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
|
||||
*
|
||||
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
|
||||
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
|
||||
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
|
||||
*/
|
||||
|
||||
/**
|
||||
* 状态码值 → 详情页中文。
|
||||
*
|
||||
* <p>码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`,
|
||||
* 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。</p>
|
||||
*/
|
||||
function toProjectStatus(raw: string | null | undefined): ProjectStatus {
|
||||
const label = PROJECT_STATUS_LABEL[raw ?? '']
|
||||
return (label as ProjectStatus | undefined) ?? '待立项'
|
||||
}
|
||||
|
||||
/**
|
||||
* 里程碑码值 → 三态。
|
||||
*
|
||||
* <p>详情页 `Milestone.status` 只有「待开始 / 进行中 / 已完成」三态(设计稿如此),
|
||||
* 而后端 `status` 是自由文本 ⇒ 按关键词归类,认不出的一律「待开始」。</p>
|
||||
*/
|
||||
function toMilestoneStatus(raw: string | null | undefined): Milestone['status'] {
|
||||
const value = (raw ?? '').toUpperCase()
|
||||
if (value.includes('COMPLET') || value.includes('DONE') || value.includes('FINISH')) return '已完成'
|
||||
if (value.includes('PROGRESS') || value.includes('DOING')) return '进行中'
|
||||
return '待开始'
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单/付款计划状态 → 三态。
|
||||
*
|
||||
* <p>⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本),
|
||||
* 只认字面量,其余一律「未到期」——不臆造成「已支付」。</p>
|
||||
*/
|
||||
function toBillStatus(raw: string | null | undefined): ProjectBill['status'] {
|
||||
const value = (raw ?? '').toUpperCase()
|
||||
if (value.includes('PAID') || value.includes('支付')) return '已支付'
|
||||
if (value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付'
|
||||
return '未到期'
|
||||
}
|
||||
|
||||
/** 需求附件 → 详情页文件项(文件 tab 用) */
|
||||
function attachmentToFile(item: RequirementAttachment): FileInfo {
|
||||
return {
|
||||
name: item.originalName ?? '',
|
||||
size: item.fileSize ?? 0,
|
||||
url: item.url ?? '',
|
||||
uploadTime: item.createTime ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 发票 → 详情页文件项(财务 tab 的发票记录只是文件列表) */
|
||||
function invoiceToFile(item: RequirementInvoice): FileInfo {
|
||||
return {
|
||||
name: item.invoiceTitle ?? item.invoiceNo ?? '发票',
|
||||
size: 0,
|
||||
url: item.invoiceFile ?? '',
|
||||
uploadTime: item.invoiceDate ?? item.createTime ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 合同 → 详情页文件项(财务 tab 只展示一份合同文件,取第一条) */
|
||||
function contractToFile(item: RequirementContract): FileInfo {
|
||||
return {
|
||||
name: item.title ?? item.contractNo ?? '合同文件',
|
||||
size: 0,
|
||||
url: item.file ?? '',
|
||||
uploadTime: item.signDate ?? item.createTime ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款计划。
|
||||
*
|
||||
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出
|
||||
* 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。</p>
|
||||
*/
|
||||
function toProjectBill(item: RequirementBillPlan): ProjectBill {
|
||||
const period = item.currentPeriod != null && item.periods != null
|
||||
? `第${item.currentPeriod}期/共${item.periods}期`
|
||||
: ''
|
||||
return {
|
||||
id: String(item.id),
|
||||
planName: item.periodRule || period || '付款计划',
|
||||
billNo: item.projectNo ?? '',
|
||||
amount: item.totalAmount ?? 0,
|
||||
status: toBillStatus(item.status),
|
||||
projectName: item.projectName ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 项目成员 → 详情页团队成员卡 */
|
||||
function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember {
|
||||
const name = item.userName ?? ''
|
||||
return {
|
||||
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
|
||||
name,
|
||||
avatarChar: name.slice(0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后工单。
|
||||
*
|
||||
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段
|
||||
* ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。</p>
|
||||
*/
|
||||
function toAfterSales(item: RequirementAfterSale): AfterSales {
|
||||
const value = (item.status ?? '').toUpperCase()
|
||||
const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW')
|
||||
return {
|
||||
status: closed ? '无' : '处理中',
|
||||
type: '',
|
||||
description: item.reason ?? '',
|
||||
remark: item.processNote ?? '',
|
||||
attachments: [],
|
||||
createTime: item.requestTime ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */
|
||||
export function toProjectDetail(vo: RequirementDetailVo): Project {
|
||||
const stage = vo.projectStage
|
||||
const contract = vo.contracts?.[0]
|
||||
return {
|
||||
id: String(vo.id),
|
||||
projectNo: vo.projectNo ?? '',
|
||||
projectName: vo.projectName ?? '',
|
||||
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
|
||||
businessType: toBusinessType(vo.businessType),
|
||||
// 列表页的 progress 就是详情侧 developmentProgress;未立项无进度 ⇒ 0
|
||||
progress: stage?.developmentProgress ?? 0,
|
||||
status: toProjectStatus(vo.status),
|
||||
submitTime: vo.createTime ?? '',
|
||||
startTime: stage?.startTime ?? undefined,
|
||||
endTime: stage?.finishTime ?? stage?.planTime ?? undefined,
|
||||
milestones: (vo.projectMilestones ?? []).map((item) => ({
|
||||
name: item.milestoneName ?? '',
|
||||
status: toMilestoneStatus(item.status),
|
||||
time: item.startTime ?? undefined,
|
||||
})),
|
||||
team: (vo.members ?? []).map(toTeamMember),
|
||||
files: (vo.requirementAttachments ?? []).map(attachmentToFile),
|
||||
paymentPlans: (vo.billPlans ?? []).map(toProjectBill),
|
||||
invoiceRecords: (vo.invoices ?? []).map(invoiceToFile),
|
||||
contractFile: contract ? contractToFile(contract) : undefined,
|
||||
afterSales: vo.afterSale ? toAfterSales(vo.afterSale) : undefined,
|
||||
}
|
||||
}
|
||||
86
src/views/cases/cpns/CaseHero.vue
Normal file
86
src/views/cases/cpns/CaseHero.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 业务类型标签(设计稿:蓝色实心胶囊 + 白字) */
|
||||
const tag = computed(() => formatBusinessType(props.item.category))
|
||||
|
||||
/**
|
||||
* 封面图。
|
||||
*
|
||||
* <p>toCase 已把「非可访问地址」收口成空串,无封面时回退主题色底,
|
||||
* 避免裂图或空白块。</p>
|
||||
*/
|
||||
const coverStyle = computed(() =>
|
||||
props.item.cover ? { backgroundImage: `url(${props.item.cover})` } : undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 设计稿:封面通栏(不随内容容器收窄、无圆角),左下叠类型标签 + 案例名 -->
|
||||
<section class="case-hero" :style="coverStyle">
|
||||
<div class="container case-hero__inner">
|
||||
<span v-if="tag" class="case-hero__tag">{{ tag }}</span>
|
||||
<h1 class="case-hero__title">
|
||||
{{ props.item.title }}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
background-color: @primary-color;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
// 底部压暗,保证白字可读
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0) 42%, rgba(15, 23, 42, 0.55));
|
||||
}
|
||||
|
||||
&__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
.flex(column, flex-end, flex-start);
|
||||
gap: @spacing-sm;
|
||||
height: 100%;
|
||||
padding-bottom: @spacing-lg;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: @white;
|
||||
font-size: @font-size-xxl;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
|
||||
// 最多两行,避免超长案例名撑破 hero
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/views/cases/cpns/CasesSearchBar.vue
Normal file
159
src/views/cases/cpns/CasesSearchBar.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
keyword: string
|
||||
/** 是否只看收藏 */
|
||||
favoriteOnly: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
search: []
|
||||
reset: []
|
||||
toggleFavoriteOnly: []
|
||||
}>()
|
||||
|
||||
const keyword = computed<string>({
|
||||
get: () => props.keyword,
|
||||
set: (value) => emit('update:keyword', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="case-search">
|
||||
<div class="case-search__field">
|
||||
<svg class="case-search__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.7-3.7" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="case-search__input"
|
||||
placeholder="关键字搜索"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="case-search__btn" @click="emit('reset')">
|
||||
重置
|
||||
</button>
|
||||
<button class="case-search__btn case-search__btn--primary" @click="emit('search')">
|
||||
搜索
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="case-search__fav"
|
||||
:class="{ 'is-active': favoriteOnly }"
|
||||
@click="emit('toggleFavoriteOnly')"
|
||||
>
|
||||
<svg class="case-search__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>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-search {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__field {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transform: translateY(-50%);
|
||||
fill: none;
|
||||
stroke: @text-color-secondary;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base 0 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: @radius-base;
|
||||
background: @input-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__fav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
margin-left: auto;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .case-search__fav-icon {
|
||||
fill: @danger-color;
|
||||
stroke: @danger-color;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
src/views/demand/cpns/BizTypeIcon.vue
Normal file
96
src/views/demand/cpns/BizTypeIcon.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
// 业务类型图标(内联 SVG,无外部图标依赖)
|
||||
// 每张卡的图标 = 浅色圆角底 + 类型主题色图形,配色取自需求提报设计稿
|
||||
import type { BizIconKey } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
/** 图标标识(由 utils/bizTypeMeta.ts 的业务类型映射表给出) */
|
||||
name: BizIconKey
|
||||
/** 边长(设计稿为 40px) */
|
||||
size?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { size: 40 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="biz-icon"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- 互联网软件:浅蓝底 + 代码尖括号 -->
|
||||
<g v-if="name === 'code'">
|
||||
<rect width="40" height="40" rx="8" fill="#e9f0ff" />
|
||||
<path
|
||||
d="M16.5 13.5 10 20l6.5 6.5M23.5 13.5 30 20l-6.5 6.5"
|
||||
stroke="#2f6bff"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 硬件电路:浅绿底 + 芯片 -->
|
||||
<g v-else-if="name === 'chip'">
|
||||
<rect width="40" height="40" rx="8" fill="#eaf6e4" />
|
||||
<rect x="12" y="12" width="16" height="16" rx="2.5" stroke="#4f9c25" stroke-width="2.2" />
|
||||
<rect x="17" y="17" width="6" height="6" rx="1" fill="#4f9c25" />
|
||||
<path
|
||||
d="M17 8v4M23 8v4M17 28v4M23 28v4M8 17h4M8 23h4M28 17h4M28 23h4"
|
||||
stroke="#4f9c25"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 物联网全案:浅紫底 + 球体网格 -->
|
||||
<g v-else-if="name === 'iot'">
|
||||
<rect width="40" height="40" rx="8" fill="#f1e9fd" />
|
||||
<circle cx="20" cy="20" r="10" stroke="#7a3fd6" stroke-width="2.2" />
|
||||
<path
|
||||
d="M20 10c3 3 3 17 0 20M20 10c-3 3-3 17 0 20M10.5 17h19M10.5 23h19"
|
||||
stroke="#7a3fd6"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 生产制造:浅橙底 + 厂房 -->
|
||||
<g v-else-if="name === 'factory'">
|
||||
<rect width="40" height="40" rx="8" fill="#fdf1e4" />
|
||||
<path
|
||||
d="M9 29V17l7 4.5V17l7 4.5V13l8 3v13z"
|
||||
stroke="#d97c14"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 29v-3.5M20 29v-3.5M26 29v-3.5"
|
||||
stroke="#d97c14"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 其他(含字典里未登记的类型):中性灰四宫格 -->
|
||||
<g v-else>
|
||||
<rect width="40" height="40" rx="8" fill="#f2f3f5" />
|
||||
<rect x="12" y="12" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="21" y="12" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="12" y="21" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="21" y="21" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.biz-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
129
src/views/demand/cpns/DemandActionBar.vue
Normal file
129
src/views/demand/cpns/DemandActionBar.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
// 底部固定操作条(设计稿:通栏白底 + 顶部细线;左侧提示 + 右侧三个按钮)
|
||||
interface Props {
|
||||
saving?: boolean
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { saving: false, submitting: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'save-draft': []
|
||||
reset: []
|
||||
submit: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-bar">
|
||||
<div class="container demand-bar__inner">
|
||||
<p class="demand-bar__tip">
|
||||
<span class="demand-bar__info" aria-hidden="true">i</span>
|
||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
||||
</p>
|
||||
<div class="demand-bar__actions">
|
||||
<button
|
||||
class="demand-bar__btn"
|
||||
type="button"
|
||||
:disabled="props.saving || props.submitting"
|
||||
@click="emit('save-draft')"
|
||||
>
|
||||
{{ props.saving ? '保存中…' : '保存草稿' }}
|
||||
</button>
|
||||
<button
|
||||
class="demand-bar__btn"
|
||||
type="button"
|
||||
:disabled="props.saving || props.submitting"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
class="demand-bar__btn demand-bar__btn--primary"
|
||||
type="button"
|
||||
:disabled="props.submitting"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitting ? '提交中…' : '立即提报' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-bar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
border-top: 1px solid @border-color;
|
||||
background: @white;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between, center);
|
||||
height: 72px;
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&__info {
|
||||
.flex(row, center, center);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @primary-color;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-end, center);
|
||||
gap: @spacing-base;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border: none;
|
||||
border-radius: @radius-round;
|
||||
background: @demand-secondary-bg;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: darken(@demand-secondary-bg, 6%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
// 设计稿按钮宽度(247px @1400 版面)由 min-width 兜住,短文案也不塌
|
||||
min-width: 240px;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
336
src/views/demand/cpns/DemandAttachments.vue
Normal file
336
src/views/demand/cpns/DemandAttachments.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
// 附件上传行 + 附件列表(真实接口:POST /web/workOrderDraft/upload → SysOssVo)
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { DemandAttachment } from '@/types/business/demand'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
files: DemandAttachment[]
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { uploading: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File]
|
||||
remove: [file: DemandAttachment]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 与后端/UploadArea 同口径:单文件不超过 1G */
|
||||
const MAX_SIZE = 1024 * 1024 * 1024
|
||||
|
||||
/** 文件类型标识色(设计稿里 PDF 红、Word 蓝等,按后缀归类) */
|
||||
const FILE_TONES: Record<string, string> = {
|
||||
pdf: '#f54a45',
|
||||
doc: '#2f6bff',
|
||||
docx: '#2f6bff',
|
||||
xls: '#1f9d55',
|
||||
xlsx: '#1f9d55',
|
||||
csv: '#1f9d55',
|
||||
ppt: '#f08a24',
|
||||
pptx: '#f08a24',
|
||||
zip: '#f08a24',
|
||||
rar: '#f08a24',
|
||||
'7z': '#f08a24',
|
||||
png: '#7a3fd6',
|
||||
jpg: '#7a3fd6',
|
||||
jpeg: '#7a3fd6',
|
||||
gif: '#7a3fd6',
|
||||
webp: '#7a3fd6',
|
||||
}
|
||||
|
||||
/** 缩写展示(长后缀截断,压缩包统一 ZIP) */
|
||||
const FILE_LABELS: Record<string, string> = {
|
||||
doc: 'DOC',
|
||||
docx: 'DOC',
|
||||
xls: 'XLS',
|
||||
xlsx: 'XLS',
|
||||
csv: 'CSV',
|
||||
ppt: 'PPT',
|
||||
pptx: 'PPT',
|
||||
zip: 'ZIP',
|
||||
rar: 'ZIP',
|
||||
'7z': 'ZIP',
|
||||
}
|
||||
|
||||
function suffixOf(file: DemandAttachment): string {
|
||||
if (file.type) return file.type.replace(/^\./, '').toLowerCase()
|
||||
const name = file.originalName ?? ''
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
function toneOf(file: DemandAttachment): string {
|
||||
return FILE_TONES[suffixOf(file)] ?? '#8a94a6'
|
||||
}
|
||||
|
||||
function labelOf(file: DemandAttachment): string {
|
||||
const suffix = suffixOf(file)
|
||||
return FILE_LABELS[suffix] ?? (suffix ? suffix.slice(0, 3).toUpperCase() : 'FILE')
|
||||
}
|
||||
|
||||
function pick(): void {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
for (const file of Array.from(input.files ?? [])) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
ElMessage.warning(`「${file.name}」超过1G,未上传`)
|
||||
continue
|
||||
}
|
||||
emit('upload', file)
|
||||
}
|
||||
// 清空以便再次选择同一个文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/** 预览:OSS 地址浏览器可直接打开(图片 / PDF) */
|
||||
function onPreview(file: DemandAttachment): void {
|
||||
if (!file.url) return
|
||||
window.open(file.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** 下载:a[download] 对跨域地址可能被忽略,加 target=_blank 兜底 */
|
||||
function onDownload(file: DemandAttachment): void {
|
||||
if (!file.url) return
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.originalName ?? ''
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
link.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-attach">
|
||||
<div class="demand-attach__head">
|
||||
<span class="demand-attach__label">附件上传</span>
|
||||
<span class="demand-attach__hint">支持图片/文档/压缩包,大小不超过1G</span>
|
||||
<button
|
||||
class="demand-attach__pick"
|
||||
type="button"
|
||||
:disabled="props.uploading"
|
||||
@click="pick"
|
||||
>
|
||||
{{ props.uploading ? '上传中…' : '上传文档' }}
|
||||
</button>
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="demand-attach__input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="onChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul v-if="props.files.length > 0" class="demand-attach__list">
|
||||
<li
|
||||
v-for="(file, index) in props.files"
|
||||
:key="`${file.ossId ?? file.originalName}-${index}`"
|
||||
class="demand-attach__item"
|
||||
>
|
||||
<span
|
||||
class="demand-attach__badge"
|
||||
:style="{ color: toneOf(file), backgroundColor: `${toneOf(file)}1a` }"
|
||||
>
|
||||
{{ labelOf(file) }}
|
||||
</span>
|
||||
<div class="demand-attach__meta">
|
||||
<span class="demand-attach__name" :title="file.originalName ?? ''">
|
||||
{{ file.originalName }}
|
||||
</span>
|
||||
<span class="demand-attach__sub">
|
||||
<template v-if="file.fileSize">{{ formatFileSize(file.fileSize) }} · </template>上传于 {{ file.createTime || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="demand-attach__acts">
|
||||
<button
|
||||
class="demand-attach__act"
|
||||
type="button"
|
||||
:disabled="!file.url"
|
||||
@click="onPreview(file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
class="demand-attach__act"
|
||||
type="button"
|
||||
:disabled="!file.url"
|
||||
@click="onDownload(file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
class="demand-attach__act demand-attach__act--danger"
|
||||
type="button"
|
||||
@click="emit('remove', file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5 5 13h6l.5-8.5M6.8 7v3.5M9.2 7v3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-attach {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__pick {
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border-radius: @radius-sm;
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: @radius-sm;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__acts {
|
||||
.flex(row, flex-end, center);
|
||||
gap: @spacing-base;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__act {
|
||||
.flex(row, center, center);
|
||||
gap: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: @danger-color;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: @danger-color;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
src/views/demand/cpns/DemandBizTypes.vue
Normal file
132
src/views/demand/cpns/DemandBizTypes.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
// ① 选择业务类型:值域 100% 来自项目提报列表 /web/projectSubmission/list(接口有几条就渲染几张卡,
|
||||
// 前端不额外追加「其他」;接口没下发就没有该选项)。
|
||||
import type { BizTypeOption } from '@/types/business/demand'
|
||||
import BizTypeIcon from './BizTypeIcon.vue'
|
||||
|
||||
interface Props {
|
||||
options: BizTypeOption[]
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="biz-types">
|
||||
<!-- 接口一条都没下发时给一行说明:不是渲染失败,而是确实没有可选类型(前端不造兜底项) -->
|
||||
<p v-if="!props.options.length" class="biz-types__empty">暂无可选业务类型</p>
|
||||
<div v-else class="biz-types__grid">
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
class="biz-types__card"
|
||||
:class="{ 'is-active': props.modelValue === option.value }"
|
||||
type="button"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
<span class="biz-types__check" aria-hidden="true" />
|
||||
<!-- 后端 Logo(已换链为可访问地址)优先,取不到时回退前端内联 SVG -->
|
||||
<img
|
||||
v-if="option.iconUrl"
|
||||
class="biz-types__logo"
|
||||
:src="option.iconUrl"
|
||||
:alt="option.label"
|
||||
>
|
||||
<BizTypeIcon
|
||||
v-else
|
||||
class="biz-types__icon"
|
||||
:name="option.icon"
|
||||
:size="40"
|
||||
/>
|
||||
<span class="biz-types__name">{{ option.label }}</span>
|
||||
<span v-if="option.desc" class="biz-types__desc">{{ option.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.biz-types {
|
||||
&__empty {
|
||||
padding: @spacing-base 0;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
// auto-fill(不是 auto-fit):条目不足 5 个时保留空轨道,卡片尺寸与设计稿一致(约 251px),
|
||||
// 不会被拉伸变形;满 5 个时正好落在设计稿的 5 列上。
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @demand-accent;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-selected-bg;
|
||||
}
|
||||
}
|
||||
|
||||
/** 右上角选中标记:未选为空心圈,选中为实心蓝点 */
|
||||
&__check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: 50%;
|
||||
background: @white;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
&__card.is-active &__check {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-accent;
|
||||
box-shadow: inset 0 0 0 3px @demand-selected-bg;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-bottom: @spacing-xs;
|
||||
}
|
||||
|
||||
/** 后端 Logo:与内联 SVG 同尺寸,保证两种来源视觉一致 */
|
||||
&__logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: @spacing-xs;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/views/demand/cpns/DemandConfirmForm.vue
Normal file
126
src/views/demand/cpns/DemandConfirmForm.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
// ③ 确认信息并补充备注:用户名称 / 用户手机号(登录账号资料预填)+ 补充备注
|
||||
import { computed } from 'vue'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ update: [partial: Partial<Demand>] }>()
|
||||
|
||||
const REMARK_MAX = 500
|
||||
|
||||
const remarkLength = computed(() => props.demand.supplementaryRemarks.length)
|
||||
|
||||
function onUserName(event: Event): void {
|
||||
emit('update', { userName: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onUserPhone(event: Event): void {
|
||||
emit('update', { userPhone: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onRemark(event: Event): void {
|
||||
emit('update', { supplementaryRemarks: (event.target as HTMLTextAreaElement).value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-confirm">
|
||||
<div class="demand-confirm__row">
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-user-name">用户名称</label>
|
||||
<input
|
||||
id="demand-user-name"
|
||||
class="demand-confirm__input"
|
||||
type="text"
|
||||
maxlength="30"
|
||||
:value="props.demand.userName"
|
||||
placeholder="请输入用户名称"
|
||||
@input="onUserName"
|
||||
>
|
||||
</div>
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-user-phone">用户手机号</label>
|
||||
<input
|
||||
id="demand-user-phone"
|
||||
class="demand-confirm__input"
|
||||
type="tel"
|
||||
maxlength="30"
|
||||
:value="props.demand.userPhone"
|
||||
placeholder="请输入用户手机号"
|
||||
@input="onUserPhone"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-remark">补充备注</label>
|
||||
<div class="demand-confirm__area">
|
||||
<textarea
|
||||
id="demand-remark"
|
||||
class="demand-confirm__textarea"
|
||||
:maxlength="REMARK_MAX"
|
||||
:value="props.demand.supplementaryRemarks"
|
||||
placeholder="其他需要说明的事项…"
|
||||
@input="onRemark"
|
||||
/>
|
||||
<span class="demand-confirm__count">{{ remarkLength }}/{{ REMARK_MAX }}字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-confirm {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1 1 50%;
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
.text-control();
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
.text-control();
|
||||
min-height: 132px;
|
||||
padding: @spacing-sm @spacing-base 24px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__count {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-xs;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
297
src/views/demand/cpns/DemandInfoForm.vue
Normal file
297
src/views/demand/cpns/DemandInfoForm.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
// ② 填写需求信息:项目名称 / 核心需求描述 / 预算范围 + 预计开发周期 / 附件上传
|
||||
//
|
||||
// 预算范围:值域来自字典 vm_budget_range,5 项同排等宽。
|
||||
// 前 4 项 → 胶囊按钮(点选 = 选中该档位)
|
||||
// 最后一项(字典 label「手填」)→ **本身就是一个输入框**,占位文案就是「手填」,
|
||||
// 直接在里面写金额;不再有"点一下额外展开一个输入框"的交互。
|
||||
// 手写内容直接写进 form.budgetRange(后端没有 budgetCustom 的落库位置),故限长 30。
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Demand, DemandAttachment, DictOption } from '@/types/business/demand'
|
||||
import { MANUAL_BUDGET_MAX, isManualBudget } from '@/utils/budgetMeta'
|
||||
import DemandAttachments from './DemandAttachments.vue'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
/** 预算范围选项(字典 vm_budget_range,手填项已由 store 排到最后) */
|
||||
budgetOptions: DictOption[]
|
||||
/** 附件是否正在上传(透传给附件区做按钮禁用) */
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { uploading: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [partial: Partial<Demand>]
|
||||
upload: [file: File]
|
||||
'remove-file': [file: DemandAttachment]
|
||||
}>()
|
||||
|
||||
const DESCRIPTION_MAX = 500
|
||||
|
||||
const descLength = computed(() => props.demand.requirementDesc.length)
|
||||
|
||||
/** 前 4 项:普通胶囊按钮 */
|
||||
const presetOptions = computed(() =>
|
||||
props.budgetOptions.filter((option) => !isManualBudget(option.dictLabel)),
|
||||
)
|
||||
|
||||
/** 最后一项:输入框(占位文案直接用字典 label,后端为「手填」) */
|
||||
const manualOption = computed(
|
||||
() => props.budgetOptions.find((option) => isManualBudget(option.dictLabel)) ?? null,
|
||||
)
|
||||
|
||||
const manualPlaceholder = computed(() => manualOption.value?.dictLabel ?? '手填')
|
||||
|
||||
/** 输入框聚焦中(用于"刚点进来还没打字"时也给选中态反馈) */
|
||||
const manualFocused = ref(false)
|
||||
|
||||
/** 输入框 DOM 引用(点预设时要把它真的失焦) */
|
||||
const manualInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 当前是否选中了某个预设胶囊(用于保证"恰有一个控件高亮") */
|
||||
const presetSelected = computed(() =>
|
||||
presetOptions.value.some((option) => option.dictValue === props.demand.budgetRange),
|
||||
)
|
||||
|
||||
/** 输入框选中态:已写入手填内容,或正在输入且此时没有选中预设 */
|
||||
const manualActive = computed(() =>
|
||||
props.demand.budgetManual || (manualFocused.value && !presetSelected.value),
|
||||
)
|
||||
|
||||
/** 输入框显示值:非手填模式下留空,把位置让给占位文案 */
|
||||
const manualValue = computed(() => (props.demand.budgetManual ? props.demand.budgetRange : ''))
|
||||
|
||||
function onProjectName(event: Event): void {
|
||||
emit('update', { projectName: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onDescription(event: Event): void {
|
||||
emit('update', { requirementDesc: (event.target as HTMLTextAreaElement).value })
|
||||
}
|
||||
|
||||
function onCycle(event: Event): void {
|
||||
const raw = (event.target as HTMLInputElement).value
|
||||
emit('update', { developmentCycle: raw === '' ? '' : Number(raw) })
|
||||
}
|
||||
|
||||
/** 预设胶囊是否选中(手填模式下预设一律不选中,避免两个控件同时高亮) */
|
||||
function isPresetActive(option: DictOption): boolean {
|
||||
return !props.demand.budgetManual && props.demand.budgetRange === option.dictValue
|
||||
}
|
||||
|
||||
function onBudget(option: DictOption): void {
|
||||
// 先让输入框真的失焦,避免"在输入框里打完字又改点档位"时两个控件同时高亮
|
||||
manualFocused.value = false
|
||||
manualInput.value?.blur()
|
||||
emit('update', { budgetRange: option.dictValue, budgetManual: false })
|
||||
}
|
||||
|
||||
/** 在输入框里打字即进入手写模式,内容直接作为 budgetRange */
|
||||
function onBudgetManualInput(event: Event): void {
|
||||
emit('update', { budgetRange: (event.target as HTMLInputElement).value, budgetManual: true })
|
||||
}
|
||||
|
||||
/** 失焦时若一个字都没写,退回"未选择"(避免空手写值卡住提交校验) */
|
||||
function onBudgetManualBlur(event: Event): void {
|
||||
manualFocused.value = false
|
||||
if ((event.target as HTMLInputElement).value.trim() === '') {
|
||||
emit('update', { budgetRange: '', budgetManual: false })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-info">
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-project-name">
|
||||
项目名称<span class="demand-info__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="demand-project-name"
|
||||
class="demand-info__input"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:value="props.demand.projectName"
|
||||
placeholder="请输入项目名称"
|
||||
@input="onProjectName"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-description">
|
||||
核心需求描述<span class="demand-info__required">*</span>
|
||||
</label>
|
||||
<div class="demand-info__area">
|
||||
<textarea
|
||||
id="demand-description"
|
||||
class="demand-info__textarea"
|
||||
:maxlength="DESCRIPTION_MAX"
|
||||
:value="props.demand.requirementDesc"
|
||||
placeholder="请详细描述您的需求,包括功能模块、使用场景、目标用户等…"
|
||||
@input="onDescription"
|
||||
/>
|
||||
<span class="demand-info__count">{{ descLength }}/{{ DESCRIPTION_MAX }}字</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__row">
|
||||
<div class="demand-info__field">
|
||||
<span class="demand-info__label">预算范围</span>
|
||||
<div class="demand-info__budgets">
|
||||
<button
|
||||
v-for="option in presetOptions"
|
||||
:key="option.dictValue"
|
||||
class="demand-info__budget"
|
||||
:class="{ 'is-active': isPresetActive(option) }"
|
||||
type="button"
|
||||
@click="onBudget(option)"
|
||||
>
|
||||
{{ option.dictLabel }}
|
||||
</button>
|
||||
<!-- 第 5 个控件即输入框:直接写金额(后端 budgetRange 列 varchar(30),故限长 30) -->
|
||||
<input
|
||||
v-if="manualOption"
|
||||
id="demand-budget-manual"
|
||||
ref="manualInput"
|
||||
class="demand-info__budget demand-info__budget--manual"
|
||||
:class="{ 'is-active': manualActive }"
|
||||
type="text"
|
||||
:maxlength="MANUAL_BUDGET_MAX"
|
||||
:value="manualValue"
|
||||
:placeholder="manualPlaceholder"
|
||||
:title="manualPlaceholder"
|
||||
@input="onBudgetManualInput"
|
||||
@focus="manualFocused = true"
|
||||
@blur="onBudgetManualBlur"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-cycle">预计开发周期</label>
|
||||
<input
|
||||
id="demand-cycle"
|
||||
class="demand-info__input"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="props.demand.developmentCycle"
|
||||
placeholder="请输入预计开发周期(工作日)"
|
||||
@input="onCycle"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DemandAttachments
|
||||
:files="props.demand.attachmentInfo"
|
||||
:uploading="props.uploading"
|
||||
@upload="(file) => emit('upload', file)"
|
||||
@remove="(file) => emit('remove-file', file)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-info {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
|
||||
.demand-info__field {
|
||||
flex: 1 1 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__required {
|
||||
margin-left: 2px;
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
.text-control();
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
.text-control();
|
||||
min-height: 132px;
|
||||
padding: @spacing-sm @spacing-base 24px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__count {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-xs;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__budgets {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
/** 预算控件外观:4 个胶囊按钮 + 第 5 个手填输入框共用同一套规格 */
|
||||
&__budget {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: @font-size-base;
|
||||
font-family: inherit;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
border-color: @demand-accent;
|
||||
color: @demand-accent;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-selected-bg;
|
||||
color: @demand-accent;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/** 手填输入框:外观同胶囊,占位文案即「手填」 */
|
||||
&__budget--manual {
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
font-weight: 400;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
317
src/views/home/cpns/HomeAppDialog.vue
Normal file
317
src/views/home/cpns/HomeAppDialog.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHomeStore } from '@/stores/modules/home'
|
||||
import type { PortalEntryCard, PortalEntryGroup } from '@/types/business/home'
|
||||
import { toPortalEntryCards } from '@/utils/entryIcon'
|
||||
|
||||
/**
|
||||
* 「更多应用」弹窗:快捷入口 / 其他入口 两组,图标角标可把入口在两组之间互转。
|
||||
*
|
||||
* 数据来自 store.quickEntries / store.otherEntries(← /vermins/portalEntryUser/list)。
|
||||
* 接口不可用(游客、无权限)时回退到内置默认入口,保证弹窗不空。
|
||||
*/
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const homeStore = useHomeStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** 接口不可用时的默认快捷入口(与首页卡片一致的固定模块) */
|
||||
const FALLBACK_QUICK: Array<Pick<PortalEntryCard, 'label' | 'route' | 'icon' | 'tone'>> = [
|
||||
{ label: '财务台账', route: '/finance/bills', icon: '💰', tone: 'amber' },
|
||||
{ label: '合同台账', route: '/contract', icon: '📄', tone: 'blue' },
|
||||
{ label: '客服工作台', route: '/service', icon: '💬', tone: 'green' },
|
||||
{ label: '案例库管理', route: '/cases', icon: '📁', tone: 'purple' },
|
||||
{ label: '工单需求池', route: '/demand', icon: '📋', tone: 'cyan' },
|
||||
]
|
||||
|
||||
/** 前端已注册的静态路由集合(用于判断入口 route 是否可跳转) */
|
||||
const knownPaths = computed(() => new Set(router.getRoutes().map((item) => item.path)))
|
||||
|
||||
/** 快捷入口组 */
|
||||
const quickCards = computed<PortalEntryCard[]>(() => {
|
||||
const list = toPortalEntryCards(homeStore.quickEntries)
|
||||
if (list.length > 0) return list
|
||||
return FALLBACK_QUICK.map((item) => ({
|
||||
...item,
|
||||
id: 0,
|
||||
groupType: '1' as PortalEntryGroup,
|
||||
isImage: false,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 其他入口组 */
|
||||
const otherCards = computed<PortalEntryCard[]>(() => toPortalEntryCards(homeStore.otherEntries))
|
||||
|
||||
/** 入口是否可点击跳转(route 为空或未注册时不跳,避免落到 404) */
|
||||
function canOpen(card: PortalEntryCard): boolean {
|
||||
return card.route.length > 0 && knownPaths.value.has(card.route)
|
||||
}
|
||||
|
||||
/** 是否是后端下发的入口(默认兜底入口 id 为 0,不可切换分组) */
|
||||
function switchable(card: PortalEntryCard): boolean {
|
||||
return card.id > 0
|
||||
}
|
||||
|
||||
/** 点击图标:跳转对应路由 */
|
||||
function onOpen(card: PortalEntryCard): void {
|
||||
if (!canOpen(card)) return
|
||||
visible.value = false
|
||||
void router.push(card.route)
|
||||
}
|
||||
|
||||
/** 点击角标:快捷 ⇄ 其他 互转 */
|
||||
async function onToggle(card: PortalEntryCard): Promise<void> {
|
||||
if (!switchable(card)) return
|
||||
const target: PortalEntryGroup = card.groupType === '1' ? '0' : '1'
|
||||
try {
|
||||
await homeStore.switchEntryGroup(card.id, target)
|
||||
} catch {
|
||||
// 失败提示已由请求层统一弹出,这里只保证不产生未捕获的 rejection
|
||||
}
|
||||
}
|
||||
|
||||
function onClose(): void {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="home-app-dialog" @click.self="onClose">
|
||||
<div class="home-app-dialog__panel">
|
||||
<div class="home-app-dialog__body">
|
||||
<section class="home-app-dialog__group">
|
||||
<h4 class="home-app-dialog__group-title">
|
||||
快捷入口
|
||||
</h4>
|
||||
<div class="home-app-dialog__grid">
|
||||
<div
|
||||
v-for="card in quickCards"
|
||||
:key="`quick-${card.label}-${card.id}`"
|
||||
class="home-app-dialog__item"
|
||||
:class="{ 'is-link': canOpen(card) }"
|
||||
>
|
||||
<button
|
||||
class="home-app-dialog__icon"
|
||||
:class="`home-app-dialog__icon--${card.tone}`"
|
||||
type="button"
|
||||
@click="onOpen(card)"
|
||||
>
|
||||
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
|
||||
<span v-else>{{ card.icon }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="switchable(card)"
|
||||
class="home-app-dialog__badge home-app-dialog__badge--remove"
|
||||
type="button"
|
||||
:aria-label="`将${card.label}移出快捷入口`"
|
||||
@click.stop="onToggle(card)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="home-app-dialog__label">{{ card.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="otherCards.length > 0" class="home-app-dialog__group">
|
||||
<h4 class="home-app-dialog__group-title">
|
||||
其他入口
|
||||
</h4>
|
||||
<div class="home-app-dialog__grid">
|
||||
<div
|
||||
v-for="card in otherCards"
|
||||
:key="`other-${card.label}-${card.id}`"
|
||||
class="home-app-dialog__item"
|
||||
:class="{ 'is-link': canOpen(card) }"
|
||||
>
|
||||
<button
|
||||
class="home-app-dialog__icon"
|
||||
:class="`home-app-dialog__icon--${card.tone}`"
|
||||
type="button"
|
||||
@click="onOpen(card)"
|
||||
>
|
||||
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
|
||||
<span v-else>{{ card.icon }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="home-app-dialog__badge home-app-dialog__badge--add"
|
||||
type="button"
|
||||
:aria-label="`将${card.label}加入快捷入口`"
|
||||
@click.stop="onToggle(card)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span class="home-app-dialog__label">{{ card.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-app-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
padding: @spacing-base;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&__panel {
|
||||
.flex(column, flex-start, stretch);
|
||||
width: 560px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 64px);
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
}
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__close {
|
||||
.flex(row, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: @radius-sm;
|
||||
background: transparent;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-lg;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @bg-color;
|
||||
color: @heading-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__group {
|
||||
& + & {
|
||||
margin-top: @spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: @spacing-base @spacing-sm;
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
.flex(column, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: @radius-base;
|
||||
font-size: @font-size-xl;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: @shadow-base;
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: #f0eaff;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #e7f8ec;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: @primary-light;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: #ffeceb;
|
||||
}
|
||||
|
||||
&--cyan {
|
||||
background: #e4f6fa;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 50%;
|
||||
margin-right: -28px;
|
||||
.flex(row, center);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--remove:hover {
|
||||
border-color: @danger-color;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
128
src/views/home/cpns/HomeBanner.vue
Normal file
128
src/views/home/cpns/HomeBanner.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { BannerVo } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
banners: BannerVo[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 仅保留启用且带图的轮播项,按后端 sort 升序 */
|
||||
const slides = computed(() =>
|
||||
props.banners
|
||||
.filter((item) => item.enabled !== false && !!item.imageUrl)
|
||||
.slice()
|
||||
.sort((prev, next) => (prev.sort ?? 0) - (next.sort ?? 0)),
|
||||
)
|
||||
|
||||
const current = ref(0)
|
||||
let timer: number | undefined
|
||||
|
||||
function stop(): void {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function go(index: number): void {
|
||||
const total = slides.value.length
|
||||
if (total === 0) return
|
||||
current.value = (index + total) % total
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
stop()
|
||||
if (slides.value.length <= 1) return
|
||||
timer = window.setInterval(() => go(current.value + 1), 5000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => slides.value.length,
|
||||
() => {
|
||||
current.value = 0
|
||||
start()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(stop)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="slides.length" class="home-banner">
|
||||
<img
|
||||
v-for="(slide, index) in slides"
|
||||
v-show="index === current"
|
||||
:key="slide.id ?? index"
|
||||
class="home-banner__img"
|
||||
:src="slide.imageUrl ?? ''"
|
||||
:alt="`轮播图 ${index + 1}`"
|
||||
>
|
||||
|
||||
<template v-if="slides.length > 1">
|
||||
<button
|
||||
class="home-banner__arrow home-banner__arrow--prev"
|
||||
type="button"
|
||||
aria-label="上一张"
|
||||
@click="go(current - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="home-banner__arrow home-banner__arrow--next"
|
||||
type="button"
|
||||
aria-label="下一张"
|
||||
@click="go(current + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-banner {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border-radius: @radius-lg;
|
||||
background: @bg-color;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: @radius-round;
|
||||
background: rgba(31, 39, 51, 0.35);
|
||||
color: @white;
|
||||
font-size: @font-size-xl;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&--prev {
|
||||
left: @spacing-base;
|
||||
}
|
||||
|
||||
&--next {
|
||||
right: @spacing-base;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
120
src/views/home/cpns/HomeServiceContents.vue
Normal file
120
src/views/home/cpns/HomeServiceContents.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServiceContentBlock } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
/** 服务内容区块列表:后端每条富文本目录一项 */
|
||||
contents: ServiceContentBlock[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 设计稿:一个「服务内容」区块 = 一条富文本目录(块内含媒体与要点列表,可多块) -->
|
||||
<div v-if="contents.length" class="home-contents">
|
||||
<section
|
||||
v-for="(block, index) in contents"
|
||||
:key="`${block.title}-${index}`"
|
||||
class="home-contents__block"
|
||||
>
|
||||
<h3 class="home-contents__title">
|
||||
服务内容
|
||||
</h3>
|
||||
<!-- 后端富文本原文(含内嵌图片/视频),取自后台维护内容 -->
|
||||
<div class="home-contents__rich" v-html="block.content" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-contents {
|
||||
&__block {
|
||||
margin-bottom: @spacing-lg;
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-base;
|
||||
padding-left: @spacing-sm;
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&__rich {
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.9;
|
||||
color: @text-color;
|
||||
word-break: break-word;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 @spacing-sm;
|
||||
}
|
||||
|
||||
:deep(ol),
|
||||
:deep(ul) {
|
||||
margin: 0 0 @spacing-sm;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
:deep(img),
|
||||
:deep(video) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4) {
|
||||
margin: @spacing-base 0 @spacing-sm;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: @spacing-sm;
|
||||
}
|
||||
|
||||
:deep(td),
|
||||
:deep(th) {
|
||||
padding: @spacing-xs @spacing-sm;
|
||||
border: 1px solid @border-color;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
src/views/home/cpns/HomeServiceTypes.vue
Normal file
110
src/views/home/cpns/HomeServiceTypes.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServiceType } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
serviceTypes: ServiceType[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
/** 后端 icon 可能是图片地址;不是地址(或为空)时用名称首字兜底展示 */
|
||||
function isImageIcon(icon?: string): boolean {
|
||||
if (!icon) return false
|
||||
return /^(https?:)?\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="serviceTypes.length" class="home-service-types">
|
||||
<h3 class="home-service-types__title">
|
||||
服务类型
|
||||
</h3>
|
||||
<ul class="home-service-types__list">
|
||||
<li
|
||||
v-for="(type, index) in serviceTypes"
|
||||
:key="`${type.label}-${index}`"
|
||||
class="home-service-types__item"
|
||||
>
|
||||
<span class="home-service-types__icon">
|
||||
<img
|
||||
v-if="isImageIcon(type.icon)"
|
||||
:src="type.icon"
|
||||
:alt="type.label"
|
||||
class="home-service-types__img"
|
||||
>
|
||||
<template v-else>
|
||||
{{ type.label.slice(0, 1) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="home-service-types__label">{{ type.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-service-types {
|
||||
margin-bottom: @spacing-lg;
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-lg;
|
||||
padding-left: @spacing-sm;
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: @spacing-lg @spacing-base;
|
||||
}
|
||||
|
||||
&__item {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: @radius-base;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
.ellipsis();
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/views/project/cpns/ProjectPager.vue
Normal file
117
src/views/project/cpns/ProjectPager.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 项目中心分页器(按设计稿:小方块、当前页为**蓝色描边**而非填充)。
|
||||
*
|
||||
* <p>为什么不复用公共 `PaginationBar`:那个是 32px 实心选中态、服务多个页面,
|
||||
* 与项目中心设计稿的 20px 描边样式不同,改它会波及其它页面。</p>
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||
const pages = computed<Array<number | '...'>>(() => {
|
||||
const n = pageCount.value
|
||||
const { page } = props
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const window: Array<number | '...'> = []
|
||||
for (let p = page - 1; p <= page + 1; p++) {
|
||||
if (p >= 1 && p <= n) window.push(p)
|
||||
}
|
||||
if (page > 3) window.unshift('...')
|
||||
if (page < n - 2) window.push('...')
|
||||
return [1, ...window.filter((item) => item !== 1 && item !== n), n].filter(
|
||||
(item, index, arr) => arr.indexOf(item) === index,
|
||||
)
|
||||
})
|
||||
|
||||
function go(page: number): void {
|
||||
if (page < 1 || page > pageCount.value || page === props.page) return
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="project-pager" aria-label="分页">
|
||||
<button
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:disabled="page <= 1"
|
||||
@click="go(page - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
v-for="(item, index) in pages"
|
||||
:key="`${item}-${index}`"
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:class="{ 'is-active': item === page, 'is-ellipsis': item === '...' }"
|
||||
:disabled="item === '...'"
|
||||
@click="go(item as number)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:disabled="page >= pageCount"
|
||||
@click="go(page + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.project-pager {
|
||||
.flex(row, flex-end, center);
|
||||
gap: 4px;
|
||||
margin-top: 15px;
|
||||
|
||||
&__btn {
|
||||
.flex(row, center, center);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: 2px;
|
||||
background: @white;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
// 设计稿:当前页是描边态(蓝框 + 蓝字),不是实心
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-ellipsis {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:not(.is-ellipsis) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
src/views/project/cpns/ProjectToolbar.vue
Normal file
145
src/views/project/cpns/ProjectToolbar.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 项目中心右侧搜索条(按设计稿:关键字输入框 + 重置 + 搜索)。
|
||||
*
|
||||
* <p>输入框比两侧按钮更高(设计稿实测 37 vs 26),三者按中线对齐。</p>
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
keyword: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const keyword = computed({
|
||||
get: () => props.keyword,
|
||||
set: (value: string) => emit('update:keyword', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-toolbar">
|
||||
<label class="project-toolbar__field">
|
||||
<svg
|
||||
class="project-toolbar__icon"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="6.8"
|
||||
cy="6.8"
|
||||
r="5.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<line
|
||||
x1="10.8"
|
||||
y1="10.8"
|
||||
x2="14.6"
|
||||
y2="14.6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="project-toolbar__input"
|
||||
type="text"
|
||||
placeholder="关键字搜索"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="project-toolbar__btn"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="project-toolbar__btn project-toolbar__btn--primary"
|
||||
@click="emit('search')"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.project-toolbar {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 24px;
|
||||
|
||||
&__field {
|
||||
.flex(row, flex-start, center);
|
||||
position: relative;
|
||||
flex: 0 1 380px;
|
||||
min-width: 0;
|
||||
height: 37px;
|
||||
border-radius: @radius-round;
|
||||
background: @input-bg;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: @text-color-disabled;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 @spacing-base 0 42px;
|
||||
border: none;
|
||||
border-radius: @radius-round;
|
||||
background: transparent;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
padding: 0 22px;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user