Compare commits

..

3 Commits

Author SHA1 Message Date
Ironsp
f1e64ef864 Merge branch 'wzp' 2026-09-28 10:02:13 +08:00
wangzzpeng
6eb2bfcbfd first1 2026-09-28 09:27:51 +08:00
wangzzpeng
7bc84bbe5e first 2026-09-24 10:10:43 +08:00
33 changed files with 5184 additions and 7 deletions

View File

@@ -1,2 +1,2 @@
# TODO(api): 待接口文档提供后填写
VITE_API_BASE_URL=
# 开发期走 vite 代理:/api 前缀由 vite.config.ts 转发到后端 8080 并去掉前缀
VITE_API_BASE_URL=/api

2
.gitignore vendored
View File

@@ -11,12 +11,10 @@ node_modules
dist
dist-ssr
*.local
read_prototype.js
RTK.md
.playwright-mcp
.omo
.opencode
.workbuddy
# Editor directories and files
.vscode/*

1
.tsc-out.txt Normal file
View File

@@ -0,0 +1 @@

1
.tsc-out2.txt Normal file
View File

@@ -0,0 +1 @@
EXIT=0

2131
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
read_prototype.js Normal file
View File

@@ -0,0 +1,22 @@
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const filePath = process.argv[2]
if (!filePath) {
console.error('Error: 请提供原型文件路径')
process.exit(1)
}
const fullPath = path.resolve(filePath)
if (fs.existsSync(fullPath)) {
console.log('=== START PROTOTYPE CONTENT ===')
console.log(fs.readFileSync(fullPath, 'utf-8'))
console.log('=== END PROTOTYPE CONTENT ===')
}
else {
console.error(`Error: 未找到原型文件 ${fullPath}`)
process.exit(1)
}

19
src/api/user.ts Normal file
View 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
}

62
src/api/web/home.ts Normal file
View File

@@ -0,0 +1,62 @@
// 用户web端首页接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
import { request } from '@/utils/request'
import type {
ProjectStatusCount,
ProjectSubmissionVo,
BannerVo,
AgreementFormVo,
} from '@/types/web/home'
/** 项目状态统计 */
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
url: '/web/home-page/project-status-count',
method: 'get',
})
}
/** 业务服务类型列表 */
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
return request<ProjectSubmissionVo[]>({
url: '/web/home-page/service-type-list',
method: 'get',
})
}
/** 业务类型详情 */
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionVo> {
return request<ProjectSubmissionVo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
}
/** 轮播图列表 */
export function getCarouselList(): Promise<BannerVo[]> {
return request<BannerVo[]>({
url: '/web/home-page/carousel-list',
method: 'get',
})
}
/** 平台介绍(富文本 HTML) */
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
/** 联系信息(富文本 HTML) */
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}

23
src/types/business/auth.d.ts vendored Normal file
View 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

65
src/types/web/home.d.ts vendored Normal file
View File

@@ -0,0 +1,65 @@
// 后端「用户web端首页」接口真实返回类型
// 对应 vermis-maker: com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 统一前缀: /web/home-page (公开接口,无需登录)
// 字段与后端 VO 严格对齐(驼峰、可空用 | null)。
/** 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/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
}

18
src/utils/asset.ts Normal file
View 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
View 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
View 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
View 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(/&nbsp;/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
View 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
View 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
View 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,
}
}

View File

@@ -3,8 +3,9 @@ import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/api'
import { storage } from '@/utils/storage'
// TODO(api): 成功码与鉴权头格式待接口文档确认
const SUCCESS_CODE = 0
// 后端 R.ok() 成功码为 200(org.dromara.common.core.constant.HttpStatus.SUCCESS)。
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
const SUCCESS_CODE = 200
/** 全项目唯一 axios 实例 */
const service = axios.create({

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -6,6 +6,16 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
// 开发期把 /api 转发到 vermis-maker 后端(8080),并去掉 /api 前缀
'/api': {
target: 'http://192.168.0.43:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -15,7 +25,8 @@ export default defineConfig({
preprocessorOptions: {
less: {
// 自动注入变量与混合宏,组件内可直接使用 @primary-color 等
additionalData: '@import "@/styles/variables.less";\n@import "@/styles/mixins.less";',
additionalData:
'@import "@/styles/variables.less";\n@import "@/styles/mixins.less";',
},
},
},