first1
This commit is contained in:
86
src/views/cases/cpns/CaseHero.vue
Normal file
86
src/views/cases/cpns/CaseHero.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 业务类型标签(设计稿:蓝色实心胶囊 + 白字) */
|
||||
const tag = computed(() => formatBusinessType(props.item.category))
|
||||
|
||||
/**
|
||||
* 封面图。
|
||||
*
|
||||
* <p>toCase 已把「非可访问地址」收口成空串,无封面时回退主题色底,
|
||||
* 避免裂图或空白块。</p>
|
||||
*/
|
||||
const coverStyle = computed(() =>
|
||||
props.item.cover ? { backgroundImage: `url(${props.item.cover})` } : undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 设计稿:封面通栏(不随内容容器收窄、无圆角),左下叠类型标签 + 案例名 -->
|
||||
<section class="case-hero" :style="coverStyle">
|
||||
<div class="container case-hero__inner">
|
||||
<span v-if="tag" class="case-hero__tag">{{ tag }}</span>
|
||||
<h1 class="case-hero__title">
|
||||
{{ props.item.title }}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
background-color: @primary-color;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
// 底部压暗,保证白字可读
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0) 42%, rgba(15, 23, 42, 0.55));
|
||||
}
|
||||
|
||||
&__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
.flex(column, flex-end, flex-start);
|
||||
gap: @spacing-sm;
|
||||
height: 100%;
|
||||
padding-bottom: @spacing-lg;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: @white;
|
||||
font-size: @font-size-xxl;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
|
||||
// 最多两行,避免超长案例名撑破 hero
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/views/cases/cpns/CasesSearchBar.vue
Normal file
159
src/views/cases/cpns/CasesSearchBar.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
keyword: string
|
||||
/** 是否只看收藏 */
|
||||
favoriteOnly: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
search: []
|
||||
reset: []
|
||||
toggleFavoriteOnly: []
|
||||
}>()
|
||||
|
||||
const keyword = computed<string>({
|
||||
get: () => props.keyword,
|
||||
set: (value) => emit('update:keyword', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="case-search">
|
||||
<div class="case-search__field">
|
||||
<svg class="case-search__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.7-3.7" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="case-search__input"
|
||||
placeholder="关键字搜索"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="case-search__btn" @click="emit('reset')">
|
||||
重置
|
||||
</button>
|
||||
<button class="case-search__btn case-search__btn--primary" @click="emit('search')">
|
||||
搜索
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="case-search__fav"
|
||||
:class="{ 'is-active': favoriteOnly }"
|
||||
@click="emit('toggleFavoriteOnly')"
|
||||
>
|
||||
<svg class="case-search__fav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 20.5s-7.2-4.6-8.4-9A5.1 5.1 0 0 1 12 5.6a5.1 5.1 0 0 1 8.4 5.9c-1.2 4.4-8.4 9-8.4 9Z" />
|
||||
</svg>
|
||||
收藏列表
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-search {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__field {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transform: translateY(-50%);
|
||||
fill: none;
|
||||
stroke: @text-color-secondary;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base 0 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: @radius-base;
|
||||
background: @input-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__fav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
margin-left: auto;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .case-search__fav-icon {
|
||||
fill: @danger-color;
|
||||
stroke: @danger-color;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
src/views/demand/cpns/BizTypeIcon.vue
Normal file
96
src/views/demand/cpns/BizTypeIcon.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
// 业务类型图标(内联 SVG,无外部图标依赖)
|
||||
// 每张卡的图标 = 浅色圆角底 + 类型主题色图形,配色取自需求提报设计稿
|
||||
import type { BizIconKey } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
/** 图标标识(由 utils/bizTypeMeta.ts 的业务类型映射表给出) */
|
||||
name: BizIconKey
|
||||
/** 边长(设计稿为 40px) */
|
||||
size?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { size: 40 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="biz-icon"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- 互联网软件:浅蓝底 + 代码尖括号 -->
|
||||
<g v-if="name === 'code'">
|
||||
<rect width="40" height="40" rx="8" fill="#e9f0ff" />
|
||||
<path
|
||||
d="M16.5 13.5 10 20l6.5 6.5M23.5 13.5 30 20l-6.5 6.5"
|
||||
stroke="#2f6bff"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 硬件电路:浅绿底 + 芯片 -->
|
||||
<g v-else-if="name === 'chip'">
|
||||
<rect width="40" height="40" rx="8" fill="#eaf6e4" />
|
||||
<rect x="12" y="12" width="16" height="16" rx="2.5" stroke="#4f9c25" stroke-width="2.2" />
|
||||
<rect x="17" y="17" width="6" height="6" rx="1" fill="#4f9c25" />
|
||||
<path
|
||||
d="M17 8v4M23 8v4M17 28v4M23 28v4M8 17h4M8 23h4M28 17h4M28 23h4"
|
||||
stroke="#4f9c25"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 物联网全案:浅紫底 + 球体网格 -->
|
||||
<g v-else-if="name === 'iot'">
|
||||
<rect width="40" height="40" rx="8" fill="#f1e9fd" />
|
||||
<circle cx="20" cy="20" r="10" stroke="#7a3fd6" stroke-width="2.2" />
|
||||
<path
|
||||
d="M20 10c3 3 3 17 0 20M20 10c-3 3-3 17 0 20M10.5 17h19M10.5 23h19"
|
||||
stroke="#7a3fd6"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 生产制造:浅橙底 + 厂房 -->
|
||||
<g v-else-if="name === 'factory'">
|
||||
<rect width="40" height="40" rx="8" fill="#fdf1e4" />
|
||||
<path
|
||||
d="M9 29V17l7 4.5V17l7 4.5V13l8 3v13z"
|
||||
stroke="#d97c14"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 29v-3.5M20 29v-3.5M26 29v-3.5"
|
||||
stroke="#d97c14"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 其他(含字典里未登记的类型):中性灰四宫格 -->
|
||||
<g v-else>
|
||||
<rect width="40" height="40" rx="8" fill="#f2f3f5" />
|
||||
<rect x="12" y="12" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="21" y="12" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="12" y="21" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
<rect x="21" y="21" width="7" height="7" rx="2" fill="#9aa3b0" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.biz-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
129
src/views/demand/cpns/DemandActionBar.vue
Normal file
129
src/views/demand/cpns/DemandActionBar.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
// 底部固定操作条(设计稿:通栏白底 + 顶部细线;左侧提示 + 右侧三个按钮)
|
||||
interface Props {
|
||||
saving?: boolean
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { saving: false, submitting: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'save-draft': []
|
||||
reset: []
|
||||
submit: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-bar">
|
||||
<div class="container demand-bar__inner">
|
||||
<p class="demand-bar__tip">
|
||||
<span class="demand-bar__info" aria-hidden="true">i</span>
|
||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
||||
</p>
|
||||
<div class="demand-bar__actions">
|
||||
<button
|
||||
class="demand-bar__btn"
|
||||
type="button"
|
||||
:disabled="props.saving || props.submitting"
|
||||
@click="emit('save-draft')"
|
||||
>
|
||||
{{ props.saving ? '保存中…' : '保存草稿' }}
|
||||
</button>
|
||||
<button
|
||||
class="demand-bar__btn"
|
||||
type="button"
|
||||
:disabled="props.saving || props.submitting"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
class="demand-bar__btn demand-bar__btn--primary"
|
||||
type="button"
|
||||
:disabled="props.submitting"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitting ? '提交中…' : '立即提报' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-bar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
border-top: 1px solid @border-color;
|
||||
background: @white;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between, center);
|
||||
height: 72px;
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&__info {
|
||||
.flex(row, center, center);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @primary-color;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-end, center);
|
||||
gap: @spacing-base;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border: none;
|
||||
border-radius: @radius-round;
|
||||
background: @demand-secondary-bg;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: darken(@demand-secondary-bg, 6%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
// 设计稿按钮宽度(247px @1400 版面)由 min-width 兜住,短文案也不塌
|
||||
min-width: 240px;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
336
src/views/demand/cpns/DemandAttachments.vue
Normal file
336
src/views/demand/cpns/DemandAttachments.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
// 附件上传行 + 附件列表(真实接口:POST /web/workOrderDraft/upload → SysOssVo)
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { DemandAttachment } from '@/types/business/demand'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
files: DemandAttachment[]
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { uploading: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File]
|
||||
remove: [file: DemandAttachment]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 与后端/UploadArea 同口径:单文件不超过 1G */
|
||||
const MAX_SIZE = 1024 * 1024 * 1024
|
||||
|
||||
/** 文件类型标识色(设计稿里 PDF 红、Word 蓝等,按后缀归类) */
|
||||
const FILE_TONES: Record<string, string> = {
|
||||
pdf: '#f54a45',
|
||||
doc: '#2f6bff',
|
||||
docx: '#2f6bff',
|
||||
xls: '#1f9d55',
|
||||
xlsx: '#1f9d55',
|
||||
csv: '#1f9d55',
|
||||
ppt: '#f08a24',
|
||||
pptx: '#f08a24',
|
||||
zip: '#f08a24',
|
||||
rar: '#f08a24',
|
||||
'7z': '#f08a24',
|
||||
png: '#7a3fd6',
|
||||
jpg: '#7a3fd6',
|
||||
jpeg: '#7a3fd6',
|
||||
gif: '#7a3fd6',
|
||||
webp: '#7a3fd6',
|
||||
}
|
||||
|
||||
/** 缩写展示(长后缀截断,压缩包统一 ZIP) */
|
||||
const FILE_LABELS: Record<string, string> = {
|
||||
doc: 'DOC',
|
||||
docx: 'DOC',
|
||||
xls: 'XLS',
|
||||
xlsx: 'XLS',
|
||||
csv: 'CSV',
|
||||
ppt: 'PPT',
|
||||
pptx: 'PPT',
|
||||
zip: 'ZIP',
|
||||
rar: 'ZIP',
|
||||
'7z': 'ZIP',
|
||||
}
|
||||
|
||||
function suffixOf(file: DemandAttachment): string {
|
||||
if (file.type) return file.type.replace(/^\./, '').toLowerCase()
|
||||
const name = file.originalName ?? ''
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
function toneOf(file: DemandAttachment): string {
|
||||
return FILE_TONES[suffixOf(file)] ?? '#8a94a6'
|
||||
}
|
||||
|
||||
function labelOf(file: DemandAttachment): string {
|
||||
const suffix = suffixOf(file)
|
||||
return FILE_LABELS[suffix] ?? (suffix ? suffix.slice(0, 3).toUpperCase() : 'FILE')
|
||||
}
|
||||
|
||||
function pick(): void {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
for (const file of Array.from(input.files ?? [])) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
ElMessage.warning(`「${file.name}」超过1G,未上传`)
|
||||
continue
|
||||
}
|
||||
emit('upload', file)
|
||||
}
|
||||
// 清空以便再次选择同一个文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/** 预览:OSS 地址浏览器可直接打开(图片 / PDF) */
|
||||
function onPreview(file: DemandAttachment): void {
|
||||
if (!file.url) return
|
||||
window.open(file.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** 下载:a[download] 对跨域地址可能被忽略,加 target=_blank 兜底 */
|
||||
function onDownload(file: DemandAttachment): void {
|
||||
if (!file.url) return
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.originalName ?? ''
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
link.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-attach">
|
||||
<div class="demand-attach__head">
|
||||
<span class="demand-attach__label">附件上传</span>
|
||||
<span class="demand-attach__hint">支持图片/文档/压缩包,大小不超过1G</span>
|
||||
<button
|
||||
class="demand-attach__pick"
|
||||
type="button"
|
||||
:disabled="props.uploading"
|
||||
@click="pick"
|
||||
>
|
||||
{{ props.uploading ? '上传中…' : '上传文档' }}
|
||||
</button>
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="demand-attach__input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="onChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul v-if="props.files.length > 0" class="demand-attach__list">
|
||||
<li
|
||||
v-for="(file, index) in props.files"
|
||||
:key="`${file.ossId ?? file.originalName}-${index}`"
|
||||
class="demand-attach__item"
|
||||
>
|
||||
<span
|
||||
class="demand-attach__badge"
|
||||
:style="{ color: toneOf(file), backgroundColor: `${toneOf(file)}1a` }"
|
||||
>
|
||||
{{ labelOf(file) }}
|
||||
</span>
|
||||
<div class="demand-attach__meta">
|
||||
<span class="demand-attach__name" :title="file.originalName ?? ''">
|
||||
{{ file.originalName }}
|
||||
</span>
|
||||
<span class="demand-attach__sub">
|
||||
<template v-if="file.fileSize">{{ formatFileSize(file.fileSize) }} · </template>上传于 {{ file.createTime || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="demand-attach__acts">
|
||||
<button
|
||||
class="demand-attach__act"
|
||||
type="button"
|
||||
:disabled="!file.url"
|
||||
@click="onPreview(file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
class="demand-attach__act"
|
||||
type="button"
|
||||
:disabled="!file.url"
|
||||
@click="onDownload(file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
class="demand-attach__act demand-attach__act--danger"
|
||||
type="button"
|
||||
@click="emit('remove', file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5 5 13h6l.5-8.5M6.8 7v3.5M9.2 7v3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-attach {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__pick {
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border-radius: @radius-sm;
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: @radius-sm;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__acts {
|
||||
.flex(row, flex-end, center);
|
||||
gap: @spacing-base;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__act {
|
||||
.flex(row, center, center);
|
||||
gap: 4px;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: @danger-color;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: @danger-color;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
src/views/demand/cpns/DemandBizTypes.vue
Normal file
132
src/views/demand/cpns/DemandBizTypes.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
// ① 选择业务类型:值域 100% 来自项目提报列表 /web/projectSubmission/list(接口有几条就渲染几张卡,
|
||||
// 前端不额外追加「其他」;接口没下发就没有该选项)。
|
||||
import type { BizTypeOption } from '@/types/business/demand'
|
||||
import BizTypeIcon from './BizTypeIcon.vue'
|
||||
|
||||
interface Props {
|
||||
options: BizTypeOption[]
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="biz-types">
|
||||
<!-- 接口一条都没下发时给一行说明:不是渲染失败,而是确实没有可选类型(前端不造兜底项) -->
|
||||
<p v-if="!props.options.length" class="biz-types__empty">暂无可选业务类型</p>
|
||||
<div v-else class="biz-types__grid">
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
class="biz-types__card"
|
||||
:class="{ 'is-active': props.modelValue === option.value }"
|
||||
type="button"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
<span class="biz-types__check" aria-hidden="true" />
|
||||
<!-- 后端 Logo(已换链为可访问地址)优先,取不到时回退前端内联 SVG -->
|
||||
<img
|
||||
v-if="option.iconUrl"
|
||||
class="biz-types__logo"
|
||||
:src="option.iconUrl"
|
||||
:alt="option.label"
|
||||
>
|
||||
<BizTypeIcon
|
||||
v-else
|
||||
class="biz-types__icon"
|
||||
:name="option.icon"
|
||||
:size="40"
|
||||
/>
|
||||
<span class="biz-types__name">{{ option.label }}</span>
|
||||
<span v-if="option.desc" class="biz-types__desc">{{ option.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.biz-types {
|
||||
&__empty {
|
||||
padding: @spacing-base 0;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
// auto-fill(不是 auto-fit):条目不足 5 个时保留空轨道,卡片尺寸与设计稿一致(约 251px),
|
||||
// 不会被拉伸变形;满 5 个时正好落在设计稿的 5 列上。
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @demand-accent;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-selected-bg;
|
||||
}
|
||||
}
|
||||
|
||||
/** 右上角选中标记:未选为空心圈,选中为实心蓝点 */
|
||||
&__check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: 50%;
|
||||
background: @white;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
&__card.is-active &__check {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-accent;
|
||||
box-shadow: inset 0 0 0 3px @demand-selected-bg;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-bottom: @spacing-xs;
|
||||
}
|
||||
|
||||
/** 后端 Logo:与内联 SVG 同尺寸,保证两种来源视觉一致 */
|
||||
&__logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: @spacing-xs;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/views/demand/cpns/DemandConfirmForm.vue
Normal file
126
src/views/demand/cpns/DemandConfirmForm.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
// ③ 确认信息并补充备注:用户名称 / 用户手机号(登录账号资料预填)+ 补充备注
|
||||
import { computed } from 'vue'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ update: [partial: Partial<Demand>] }>()
|
||||
|
||||
const REMARK_MAX = 500
|
||||
|
||||
const remarkLength = computed(() => props.demand.supplementaryRemarks.length)
|
||||
|
||||
function onUserName(event: Event): void {
|
||||
emit('update', { userName: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onUserPhone(event: Event): void {
|
||||
emit('update', { userPhone: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onRemark(event: Event): void {
|
||||
emit('update', { supplementaryRemarks: (event.target as HTMLTextAreaElement).value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-confirm">
|
||||
<div class="demand-confirm__row">
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-user-name">用户名称</label>
|
||||
<input
|
||||
id="demand-user-name"
|
||||
class="demand-confirm__input"
|
||||
type="text"
|
||||
maxlength="30"
|
||||
:value="props.demand.userName"
|
||||
placeholder="请输入用户名称"
|
||||
@input="onUserName"
|
||||
>
|
||||
</div>
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-user-phone">用户手机号</label>
|
||||
<input
|
||||
id="demand-user-phone"
|
||||
class="demand-confirm__input"
|
||||
type="tel"
|
||||
maxlength="30"
|
||||
:value="props.demand.userPhone"
|
||||
placeholder="请输入用户手机号"
|
||||
@input="onUserPhone"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-confirm__field">
|
||||
<label class="demand-confirm__label" for="demand-remark">补充备注</label>
|
||||
<div class="demand-confirm__area">
|
||||
<textarea
|
||||
id="demand-remark"
|
||||
class="demand-confirm__textarea"
|
||||
:maxlength="REMARK_MAX"
|
||||
:value="props.demand.supplementaryRemarks"
|
||||
placeholder="其他需要说明的事项…"
|
||||
@input="onRemark"
|
||||
/>
|
||||
<span class="demand-confirm__count">{{ remarkLength }}/{{ REMARK_MAX }}字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-confirm {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1 1 50%;
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
.text-control();
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
.text-control();
|
||||
min-height: 132px;
|
||||
padding: @spacing-sm @spacing-base 24px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__count {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-xs;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
297
src/views/demand/cpns/DemandInfoForm.vue
Normal file
297
src/views/demand/cpns/DemandInfoForm.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
// ② 填写需求信息:项目名称 / 核心需求描述 / 预算范围 + 预计开发周期 / 附件上传
|
||||
//
|
||||
// 预算范围:值域来自字典 vm_budget_range,5 项同排等宽。
|
||||
// 前 4 项 → 胶囊按钮(点选 = 选中该档位)
|
||||
// 最后一项(字典 label「手填」)→ **本身就是一个输入框**,占位文案就是「手填」,
|
||||
// 直接在里面写金额;不再有"点一下额外展开一个输入框"的交互。
|
||||
// 手写内容直接写进 form.budgetRange(后端没有 budgetCustom 的落库位置),故限长 30。
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Demand, DemandAttachment, DictOption } from '@/types/business/demand'
|
||||
import { MANUAL_BUDGET_MAX, isManualBudget } from '@/utils/budgetMeta'
|
||||
import DemandAttachments from './DemandAttachments.vue'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
/** 预算范围选项(字典 vm_budget_range,手填项已由 store 排到最后) */
|
||||
budgetOptions: DictOption[]
|
||||
/** 附件是否正在上传(透传给附件区做按钮禁用) */
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { uploading: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [partial: Partial<Demand>]
|
||||
upload: [file: File]
|
||||
'remove-file': [file: DemandAttachment]
|
||||
}>()
|
||||
|
||||
const DESCRIPTION_MAX = 500
|
||||
|
||||
const descLength = computed(() => props.demand.requirementDesc.length)
|
||||
|
||||
/** 前 4 项:普通胶囊按钮 */
|
||||
const presetOptions = computed(() =>
|
||||
props.budgetOptions.filter((option) => !isManualBudget(option.dictLabel)),
|
||||
)
|
||||
|
||||
/** 最后一项:输入框(占位文案直接用字典 label,后端为「手填」) */
|
||||
const manualOption = computed(
|
||||
() => props.budgetOptions.find((option) => isManualBudget(option.dictLabel)) ?? null,
|
||||
)
|
||||
|
||||
const manualPlaceholder = computed(() => manualOption.value?.dictLabel ?? '手填')
|
||||
|
||||
/** 输入框聚焦中(用于"刚点进来还没打字"时也给选中态反馈) */
|
||||
const manualFocused = ref(false)
|
||||
|
||||
/** 输入框 DOM 引用(点预设时要把它真的失焦) */
|
||||
const manualInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 当前是否选中了某个预设胶囊(用于保证"恰有一个控件高亮") */
|
||||
const presetSelected = computed(() =>
|
||||
presetOptions.value.some((option) => option.dictValue === props.demand.budgetRange),
|
||||
)
|
||||
|
||||
/** 输入框选中态:已写入手填内容,或正在输入且此时没有选中预设 */
|
||||
const manualActive = computed(() =>
|
||||
props.demand.budgetManual || (manualFocused.value && !presetSelected.value),
|
||||
)
|
||||
|
||||
/** 输入框显示值:非手填模式下留空,把位置让给占位文案 */
|
||||
const manualValue = computed(() => (props.demand.budgetManual ? props.demand.budgetRange : ''))
|
||||
|
||||
function onProjectName(event: Event): void {
|
||||
emit('update', { projectName: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function onDescription(event: Event): void {
|
||||
emit('update', { requirementDesc: (event.target as HTMLTextAreaElement).value })
|
||||
}
|
||||
|
||||
function onCycle(event: Event): void {
|
||||
const raw = (event.target as HTMLInputElement).value
|
||||
emit('update', { developmentCycle: raw === '' ? '' : Number(raw) })
|
||||
}
|
||||
|
||||
/** 预设胶囊是否选中(手填模式下预设一律不选中,避免两个控件同时高亮) */
|
||||
function isPresetActive(option: DictOption): boolean {
|
||||
return !props.demand.budgetManual && props.demand.budgetRange === option.dictValue
|
||||
}
|
||||
|
||||
function onBudget(option: DictOption): void {
|
||||
// 先让输入框真的失焦,避免"在输入框里打完字又改点档位"时两个控件同时高亮
|
||||
manualFocused.value = false
|
||||
manualInput.value?.blur()
|
||||
emit('update', { budgetRange: option.dictValue, budgetManual: false })
|
||||
}
|
||||
|
||||
/** 在输入框里打字即进入手写模式,内容直接作为 budgetRange */
|
||||
function onBudgetManualInput(event: Event): void {
|
||||
emit('update', { budgetRange: (event.target as HTMLInputElement).value, budgetManual: true })
|
||||
}
|
||||
|
||||
/** 失焦时若一个字都没写,退回"未选择"(避免空手写值卡住提交校验) */
|
||||
function onBudgetManualBlur(event: Event): void {
|
||||
manualFocused.value = false
|
||||
if ((event.target as HTMLInputElement).value.trim() === '') {
|
||||
emit('update', { budgetRange: '', budgetManual: false })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-info">
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-project-name">
|
||||
项目名称<span class="demand-info__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="demand-project-name"
|
||||
class="demand-info__input"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:value="props.demand.projectName"
|
||||
placeholder="请输入项目名称"
|
||||
@input="onProjectName"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-description">
|
||||
核心需求描述<span class="demand-info__required">*</span>
|
||||
</label>
|
||||
<div class="demand-info__area">
|
||||
<textarea
|
||||
id="demand-description"
|
||||
class="demand-info__textarea"
|
||||
:maxlength="DESCRIPTION_MAX"
|
||||
:value="props.demand.requirementDesc"
|
||||
placeholder="请详细描述您的需求,包括功能模块、使用场景、目标用户等…"
|
||||
@input="onDescription"
|
||||
/>
|
||||
<span class="demand-info__count">{{ descLength }}/{{ DESCRIPTION_MAX }}字</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__row">
|
||||
<div class="demand-info__field">
|
||||
<span class="demand-info__label">预算范围</span>
|
||||
<div class="demand-info__budgets">
|
||||
<button
|
||||
v-for="option in presetOptions"
|
||||
:key="option.dictValue"
|
||||
class="demand-info__budget"
|
||||
:class="{ 'is-active': isPresetActive(option) }"
|
||||
type="button"
|
||||
@click="onBudget(option)"
|
||||
>
|
||||
{{ option.dictLabel }}
|
||||
</button>
|
||||
<!-- 第 5 个控件即输入框:直接写金额(后端 budgetRange 列 varchar(30),故限长 30) -->
|
||||
<input
|
||||
v-if="manualOption"
|
||||
id="demand-budget-manual"
|
||||
ref="manualInput"
|
||||
class="demand-info__budget demand-info__budget--manual"
|
||||
:class="{ 'is-active': manualActive }"
|
||||
type="text"
|
||||
:maxlength="MANUAL_BUDGET_MAX"
|
||||
:value="manualValue"
|
||||
:placeholder="manualPlaceholder"
|
||||
:title="manualPlaceholder"
|
||||
@input="onBudgetManualInput"
|
||||
@focus="manualFocused = true"
|
||||
@blur="onBudgetManualBlur"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-info__field">
|
||||
<label class="demand-info__label" for="demand-cycle">预计开发周期</label>
|
||||
<input
|
||||
id="demand-cycle"
|
||||
class="demand-info__input"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="props.demand.developmentCycle"
|
||||
placeholder="请输入预计开发周期(工作日)"
|
||||
@input="onCycle"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DemandAttachments
|
||||
:files="props.demand.attachmentInfo"
|
||||
:uploading="props.uploading"
|
||||
@upload="(file) => emit('upload', file)"
|
||||
@remove="(file) => emit('remove-file', file)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-info {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
|
||||
.demand-info__field {
|
||||
flex: 1 1 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__required {
|
||||
margin-left: 2px;
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
.text-control();
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
.text-control();
|
||||
min-height: 132px;
|
||||
padding: @spacing-sm @spacing-base 24px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__count {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-xs;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__budgets {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
/** 预算控件外观:4 个胶囊按钮 + 第 5 个手填输入框共用同一套规格 */
|
||||
&__budget {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
font-size: @font-size-base;
|
||||
font-family: inherit;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
border-color: @demand-accent;
|
||||
color: @demand-accent;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @demand-accent;
|
||||
background: @demand-selected-bg;
|
||||
color: @demand-accent;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/** 手填输入框:外观同胶囊,占位文案即「手填」 */
|
||||
&__budget--manual {
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
font-weight: 400;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
317
src/views/home/cpns/HomeAppDialog.vue
Normal file
317
src/views/home/cpns/HomeAppDialog.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHomeStore } from '@/stores/modules/home'
|
||||
import type { PortalEntryCard, PortalEntryGroup } from '@/types/business/home'
|
||||
import { toPortalEntryCards } from '@/utils/entryIcon'
|
||||
|
||||
/**
|
||||
* 「更多应用」弹窗:快捷入口 / 其他入口 两组,图标角标可把入口在两组之间互转。
|
||||
*
|
||||
* 数据来自 store.quickEntries / store.otherEntries(← /vermins/portalEntryUser/list)。
|
||||
* 接口不可用(游客、无权限)时回退到内置默认入口,保证弹窗不空。
|
||||
*/
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const homeStore = useHomeStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** 接口不可用时的默认快捷入口(与首页卡片一致的固定模块) */
|
||||
const FALLBACK_QUICK: Array<Pick<PortalEntryCard, 'label' | 'route' | 'icon' | 'tone'>> = [
|
||||
{ label: '财务台账', route: '/finance/bills', icon: '💰', tone: 'amber' },
|
||||
{ label: '合同台账', route: '/contract', icon: '📄', tone: 'blue' },
|
||||
{ label: '客服工作台', route: '/service', icon: '💬', tone: 'green' },
|
||||
{ label: '案例库管理', route: '/cases', icon: '📁', tone: 'purple' },
|
||||
{ label: '工单需求池', route: '/demand', icon: '📋', tone: 'cyan' },
|
||||
]
|
||||
|
||||
/** 前端已注册的静态路由集合(用于判断入口 route 是否可跳转) */
|
||||
const knownPaths = computed(() => new Set(router.getRoutes().map((item) => item.path)))
|
||||
|
||||
/** 快捷入口组 */
|
||||
const quickCards = computed<PortalEntryCard[]>(() => {
|
||||
const list = toPortalEntryCards(homeStore.quickEntries)
|
||||
if (list.length > 0) return list
|
||||
return FALLBACK_QUICK.map((item) => ({
|
||||
...item,
|
||||
id: 0,
|
||||
groupType: '1' as PortalEntryGroup,
|
||||
isImage: false,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 其他入口组 */
|
||||
const otherCards = computed<PortalEntryCard[]>(() => toPortalEntryCards(homeStore.otherEntries))
|
||||
|
||||
/** 入口是否可点击跳转(route 为空或未注册时不跳,避免落到 404) */
|
||||
function canOpen(card: PortalEntryCard): boolean {
|
||||
return card.route.length > 0 && knownPaths.value.has(card.route)
|
||||
}
|
||||
|
||||
/** 是否是后端下发的入口(默认兜底入口 id 为 0,不可切换分组) */
|
||||
function switchable(card: PortalEntryCard): boolean {
|
||||
return card.id > 0
|
||||
}
|
||||
|
||||
/** 点击图标:跳转对应路由 */
|
||||
function onOpen(card: PortalEntryCard): void {
|
||||
if (!canOpen(card)) return
|
||||
visible.value = false
|
||||
void router.push(card.route)
|
||||
}
|
||||
|
||||
/** 点击角标:快捷 ⇄ 其他 互转 */
|
||||
async function onToggle(card: PortalEntryCard): Promise<void> {
|
||||
if (!switchable(card)) return
|
||||
const target: PortalEntryGroup = card.groupType === '1' ? '0' : '1'
|
||||
try {
|
||||
await homeStore.switchEntryGroup(card.id, target)
|
||||
} catch {
|
||||
// 失败提示已由请求层统一弹出,这里只保证不产生未捕获的 rejection
|
||||
}
|
||||
}
|
||||
|
||||
function onClose(): void {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="home-app-dialog" @click.self="onClose">
|
||||
<div class="home-app-dialog__panel">
|
||||
<div class="home-app-dialog__body">
|
||||
<section class="home-app-dialog__group">
|
||||
<h4 class="home-app-dialog__group-title">
|
||||
快捷入口
|
||||
</h4>
|
||||
<div class="home-app-dialog__grid">
|
||||
<div
|
||||
v-for="card in quickCards"
|
||||
:key="`quick-${card.label}-${card.id}`"
|
||||
class="home-app-dialog__item"
|
||||
:class="{ 'is-link': canOpen(card) }"
|
||||
>
|
||||
<button
|
||||
class="home-app-dialog__icon"
|
||||
:class="`home-app-dialog__icon--${card.tone}`"
|
||||
type="button"
|
||||
@click="onOpen(card)"
|
||||
>
|
||||
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
|
||||
<span v-else>{{ card.icon }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="switchable(card)"
|
||||
class="home-app-dialog__badge home-app-dialog__badge--remove"
|
||||
type="button"
|
||||
:aria-label="`将${card.label}移出快捷入口`"
|
||||
@click.stop="onToggle(card)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="home-app-dialog__label">{{ card.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="otherCards.length > 0" class="home-app-dialog__group">
|
||||
<h4 class="home-app-dialog__group-title">
|
||||
其他入口
|
||||
</h4>
|
||||
<div class="home-app-dialog__grid">
|
||||
<div
|
||||
v-for="card in otherCards"
|
||||
:key="`other-${card.label}-${card.id}`"
|
||||
class="home-app-dialog__item"
|
||||
:class="{ 'is-link': canOpen(card) }"
|
||||
>
|
||||
<button
|
||||
class="home-app-dialog__icon"
|
||||
:class="`home-app-dialog__icon--${card.tone}`"
|
||||
type="button"
|
||||
@click="onOpen(card)"
|
||||
>
|
||||
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
|
||||
<span v-else>{{ card.icon }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="home-app-dialog__badge home-app-dialog__badge--add"
|
||||
type="button"
|
||||
:aria-label="`将${card.label}加入快捷入口`"
|
||||
@click.stop="onToggle(card)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span class="home-app-dialog__label">{{ card.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-app-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
padding: @spacing-base;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&__panel {
|
||||
.flex(column, flex-start, stretch);
|
||||
width: 560px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 64px);
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
}
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__close {
|
||||
.flex(row, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: @radius-sm;
|
||||
background: transparent;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-lg;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @bg-color;
|
||||
color: @heading-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__group {
|
||||
& + & {
|
||||
margin-top: @spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: @spacing-base @spacing-sm;
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
.flex(column, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: @radius-base;
|
||||
font-size: @font-size-xl;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: @shadow-base;
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: #f0eaff;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #e7f8ec;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: @primary-light;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: #ffeceb;
|
||||
}
|
||||
|
||||
&--cyan {
|
||||
background: #e4f6fa;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 50%;
|
||||
margin-right: -28px;
|
||||
.flex(row, center);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--remove:hover {
|
||||
border-color: @danger-color;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
128
src/views/home/cpns/HomeBanner.vue
Normal file
128
src/views/home/cpns/HomeBanner.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { BannerVo } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
banners: BannerVo[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 仅保留启用且带图的轮播项,按后端 sort 升序 */
|
||||
const slides = computed(() =>
|
||||
props.banners
|
||||
.filter((item) => item.enabled !== false && !!item.imageUrl)
|
||||
.slice()
|
||||
.sort((prev, next) => (prev.sort ?? 0) - (next.sort ?? 0)),
|
||||
)
|
||||
|
||||
const current = ref(0)
|
||||
let timer: number | undefined
|
||||
|
||||
function stop(): void {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function go(index: number): void {
|
||||
const total = slides.value.length
|
||||
if (total === 0) return
|
||||
current.value = (index + total) % total
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
stop()
|
||||
if (slides.value.length <= 1) return
|
||||
timer = window.setInterval(() => go(current.value + 1), 5000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => slides.value.length,
|
||||
() => {
|
||||
current.value = 0
|
||||
start()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(stop)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="slides.length" class="home-banner">
|
||||
<img
|
||||
v-for="(slide, index) in slides"
|
||||
v-show="index === current"
|
||||
:key="slide.id ?? index"
|
||||
class="home-banner__img"
|
||||
:src="slide.imageUrl ?? ''"
|
||||
:alt="`轮播图 ${index + 1}`"
|
||||
>
|
||||
|
||||
<template v-if="slides.length > 1">
|
||||
<button
|
||||
class="home-banner__arrow home-banner__arrow--prev"
|
||||
type="button"
|
||||
aria-label="上一张"
|
||||
@click="go(current - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="home-banner__arrow home-banner__arrow--next"
|
||||
type="button"
|
||||
aria-label="下一张"
|
||||
@click="go(current + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-banner {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border-radius: @radius-lg;
|
||||
background: @bg-color;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: @radius-round;
|
||||
background: rgba(31, 39, 51, 0.35);
|
||||
color: @white;
|
||||
font-size: @font-size-xl;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&--prev {
|
||||
left: @spacing-base;
|
||||
}
|
||||
|
||||
&--next {
|
||||
right: @spacing-base;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
120
src/views/home/cpns/HomeServiceContents.vue
Normal file
120
src/views/home/cpns/HomeServiceContents.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServiceContentBlock } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
/** 服务内容区块列表:后端每条富文本目录一项 */
|
||||
contents: ServiceContentBlock[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 设计稿:一个「服务内容」区块 = 一条富文本目录(块内含媒体与要点列表,可多块) -->
|
||||
<div v-if="contents.length" class="home-contents">
|
||||
<section
|
||||
v-for="(block, index) in contents"
|
||||
:key="`${block.title}-${index}`"
|
||||
class="home-contents__block"
|
||||
>
|
||||
<h3 class="home-contents__title">
|
||||
服务内容
|
||||
</h3>
|
||||
<!-- 后端富文本原文(含内嵌图片/视频),取自后台维护内容 -->
|
||||
<div class="home-contents__rich" v-html="block.content" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-contents {
|
||||
&__block {
|
||||
margin-bottom: @spacing-lg;
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-base;
|
||||
padding-left: @spacing-sm;
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&__rich {
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.9;
|
||||
color: @text-color;
|
||||
word-break: break-word;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 @spacing-sm;
|
||||
}
|
||||
|
||||
:deep(ol),
|
||||
:deep(ul) {
|
||||
margin: 0 0 @spacing-sm;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
:deep(img),
|
||||
:deep(video) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4) {
|
||||
margin: @spacing-base 0 @spacing-sm;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: @spacing-sm;
|
||||
}
|
||||
|
||||
:deep(td),
|
||||
:deep(th) {
|
||||
padding: @spacing-xs @spacing-sm;
|
||||
border: 1px solid @border-color;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
src/views/home/cpns/HomeServiceTypes.vue
Normal file
110
src/views/home/cpns/HomeServiceTypes.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServiceType } from '@/types/business/home'
|
||||
|
||||
interface Props {
|
||||
serviceTypes: ServiceType[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
/** 后端 icon 可能是图片地址;不是地址(或为空)时用名称首字兜底展示 */
|
||||
function isImageIcon(icon?: string): boolean {
|
||||
if (!icon) return false
|
||||
return /^(https?:)?\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="serviceTypes.length" class="home-service-types">
|
||||
<h3 class="home-service-types__title">
|
||||
服务类型
|
||||
</h3>
|
||||
<ul class="home-service-types__list">
|
||||
<li
|
||||
v-for="(type, index) in serviceTypes"
|
||||
:key="`${type.label}-${index}`"
|
||||
class="home-service-types__item"
|
||||
>
|
||||
<span class="home-service-types__icon">
|
||||
<img
|
||||
v-if="isImageIcon(type.icon)"
|
||||
:src="type.icon"
|
||||
:alt="type.label"
|
||||
class="home-service-types__img"
|
||||
>
|
||||
<template v-else>
|
||||
{{ type.label.slice(0, 1) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="home-service-types__label">{{ type.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home-service-types {
|
||||
margin-bottom: @spacing-lg;
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-lg;
|
||||
padding-left: @spacing-sm;
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: @spacing-lg @spacing-base;
|
||||
}
|
||||
|
||||
&__item {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: @radius-base;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
.ellipsis();
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/views/project/cpns/ProjectPager.vue
Normal file
117
src/views/project/cpns/ProjectPager.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 项目中心分页器(按设计稿:小方块、当前页为**蓝色描边**而非填充)。
|
||||
*
|
||||
* <p>为什么不复用公共 `PaginationBar`:那个是 32px 实心选中态、服务多个页面,
|
||||
* 与项目中心设计稿的 20px 描边样式不同,改它会波及其它页面。</p>
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||
const pages = computed<Array<number | '...'>>(() => {
|
||||
const n = pageCount.value
|
||||
const { page } = props
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const window: Array<number | '...'> = []
|
||||
for (let p = page - 1; p <= page + 1; p++) {
|
||||
if (p >= 1 && p <= n) window.push(p)
|
||||
}
|
||||
if (page > 3) window.unshift('...')
|
||||
if (page < n - 2) window.push('...')
|
||||
return [1, ...window.filter((item) => item !== 1 && item !== n), n].filter(
|
||||
(item, index, arr) => arr.indexOf(item) === index,
|
||||
)
|
||||
})
|
||||
|
||||
function go(page: number): void {
|
||||
if (page < 1 || page > pageCount.value || page === props.page) return
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="project-pager" aria-label="分页">
|
||||
<button
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:disabled="page <= 1"
|
||||
@click="go(page - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
v-for="(item, index) in pages"
|
||||
:key="`${item}-${index}`"
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:class="{ 'is-active': item === page, 'is-ellipsis': item === '...' }"
|
||||
:disabled="item === '...'"
|
||||
@click="go(item as number)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="project-pager__btn"
|
||||
:disabled="page >= pageCount"
|
||||
@click="go(page + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.project-pager {
|
||||
.flex(row, flex-end, center);
|
||||
gap: 4px;
|
||||
margin-top: 15px;
|
||||
|
||||
&__btn {
|
||||
.flex(row, center, center);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: 2px;
|
||||
background: @white;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
// 设计稿:当前页是描边态(蓝框 + 蓝字),不是实心
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-ellipsis {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:not(.is-ellipsis) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
src/views/project/cpns/ProjectToolbar.vue
Normal file
145
src/views/project/cpns/ProjectToolbar.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 项目中心右侧搜索条(按设计稿:关键字输入框 + 重置 + 搜索)。
|
||||
*
|
||||
* <p>输入框比两侧按钮更高(设计稿实测 37 vs 26),三者按中线对齐。</p>
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
keyword: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const keyword = computed({
|
||||
get: () => props.keyword,
|
||||
set: (value: string) => emit('update:keyword', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-toolbar">
|
||||
<label class="project-toolbar__field">
|
||||
<svg
|
||||
class="project-toolbar__icon"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="6.8"
|
||||
cy="6.8"
|
||||
r="5.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<line
|
||||
x1="10.8"
|
||||
y1="10.8"
|
||||
x2="14.6"
|
||||
y2="14.6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="project-toolbar__input"
|
||||
type="text"
|
||||
placeholder="关键字搜索"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="project-toolbar__btn"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="project-toolbar__btn project-toolbar__btn--primary"
|
||||
@click="emit('search')"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.project-toolbar {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 24px;
|
||||
|
||||
&__field {
|
||||
.flex(row, flex-start, center);
|
||||
position: relative;
|
||||
flex: 0 1 380px;
|
||||
min-width: 0;
|
||||
height: 37px;
|
||||
border-radius: @radius-round;
|
||||
background: @input-bg;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: @text-color-disabled;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 @spacing-base 0 42px;
|
||||
border: none;
|
||||
border-radius: @radius-round;
|
||||
background: transparent;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
padding: 0 22px;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user