Merge branch 'wzp'
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
# TODO(api): 待接口文档提供后填写
|
# 开发期走 vite 代理:/api 前缀由 vite.config.ts 转发到后端 8080 并去掉前缀
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,12 +11,10 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
read_prototype.js
|
|
||||||
RTK.md
|
RTK.md
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
.omo
|
.omo
|
||||||
.opencode
|
.opencode
|
||||||
.workbuddy
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
1
.tsc-out.txt
Normal file
1
.tsc-out.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
.tsc-out2.txt
Normal file
1
.tsc-out2.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXIT=0
|
||||||
2131
package-lock.json
generated
Normal file
2131
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
read_prototype.js
Normal file
22
read_prototype.js
Normal 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
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
|
||||||
|
}
|
||||||
62
src/api/web/home.ts
Normal file
62
src/api/web/home.ts
Normal 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
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
|
||||||
65
src/types/web/home.d.ts
vendored
Normal file
65
src/types/web/home.d.ts
vendored
Normal 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
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { ElMessage } from 'element-plus'
|
|||||||
import type { ApiResponse } from '@/types/api'
|
import type { ApiResponse } from '@/types/api'
|
||||||
import { storage } from '@/utils/storage'
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
// TODO(api): 成功码与鉴权头格式待接口文档确认
|
// 后端 R.ok() 成功码为 200(org.dromara.common.core.constant.HttpStatus.SUCCESS)。
|
||||||
const SUCCESS_CODE = 0
|
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
|
||||||
|
const SUCCESS_CODE = 200
|
||||||
|
|
||||||
/** 全项目唯一 axios 实例 */
|
/** 全项目唯一 axios 实例 */
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
|
|||||||
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>
|
||||||
@@ -6,6 +6,16 @@ import { defineConfig } from 'vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@@ -15,7 +25,8 @@ export default defineConfig({
|
|||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
less: {
|
less: {
|
||||||
// 自动注入变量与混合宏,组件内可直接使用 @primary-color 等
|
// 自动注入变量与混合宏,组件内可直接使用 @primary-color 等
|
||||||
additionalData: '@import "@/styles/variables.less";\n@import "@/styles/mixins.less";',
|
additionalData:
|
||||||
|
'@import "@/styles/variables.less";\n@import "@/styles/mixins.less";',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user