feat:首页完成

This commit is contained in:
李伊帆
2026-09-11 08:02:01 +08:00
parent 5b31eac508
commit 173cd6b975
68 changed files with 3181 additions and 112 deletions

View File

@@ -5,14 +5,18 @@ const props = defineProps<{
active: TabKey active: TabKey
}>() }>()
const tabs: Array<{ key: TabKey, label: string, icon: string, path: string }> = [ const tabs: Array<{ key: TabKey, label: string, icon: string, activeIcon: string, path: string }> = [
{ key: 'home', label: '首页', icon: '/static/icons/tab-home.svg', path: '/pages/home-index/index' }, { key: 'home', label: '首页', icon: '/static/icons/tab-home.png', activeIcon: '/static/icons/tab-home-active.png', path: '/pages/home-index/index' },
{ key: 'case', label: '案例库', icon: '/static/icons/tab-case.svg', path: '/pages/case-library/index' }, { key: 'case', label: '案例库', icon: '/static/icons/tab-case.png', activeIcon: '/static/icons/tab-case-active.png', path: '/pages/case-library/index' },
{ key: 'message', label: '消息中心', icon: '/static/icons/tab-message.svg', path: '/pages/message-center/index' }, { key: 'message', label: '消息中心', icon: '/static/icons/tab-message.png', activeIcon: '/static/icons/tab-message-active.png', path: '/pages/message-center/index' },
{ key: 'project', label: '项目中心', icon: '/static/icons/tab-project.svg', path: '/pages/project-center/index' }, { key: 'project', label: '项目中心', icon: '/static/icons/tab-project.png', activeIcon: '/static/icons/tab-project-active.png', path: '/pages/project-center/index' },
{ key: 'personal', label: '个人中心', icon: '/static/icons/tab-personal.svg', path: '/pages/personal-center/index' }, { key: 'personal', label: '个人中心', icon: '/static/icons/tab-personal.png', activeIcon: '/static/icons/tab-personal-active.png', path: '/pages/personal-center/index' },
] ]
function getTabIcon(tab: typeof tabs[number]) {
return tab.key === props.active ? tab.activeIcon : tab.icon
}
function handleSwitch(path: string, key: TabKey) { function handleSwitch(path: string, key: TabKey) {
if (key === props.active) if (key === props.active)
return return
@@ -30,8 +34,7 @@ function handleSwitch(path: string, key: TabKey) {
> >
<image <image
class="app-tab-bar__icon" class="app-tab-bar__icon"
:class="{ 'app-tab-bar__icon--active': tab.key === active }" :src="getTabIcon(tab)"
:src="tab.icon"
mode="aspectFit" mode="aspectFit"
/> />
<text <text
@@ -71,11 +74,6 @@ function handleSwitch(path: string, key: TabKey) {
.app-tab-bar__icon { .app-tab-bar__icon {
width: 44rpx; width: 44rpx;
height: 44rpx; height: 44rpx;
opacity: 0.45;
&--active {
opacity: 1;
}
} }
.app-tab-bar__label { .app-tab-bar__label {

View File

@@ -0,0 +1,34 @@
import type { BusinessDetailContent } from '@/types/business-detail'
import softwareIllus from '@/static/icons/software.png'
export const BUSINESS_DETAIL_MAP: Record<string, BusinessDetailContent> = {
software: {
id: 'software',
title: '互联网软件',
subtitle: '专业的软件定制开发服务,从需求到上线全流程保障',
icon: softwareIllus,
serviceTypes: [
{ id: 'web', label: 'Web应用', icon: '/static/icons/service-web.png' },
{ id: 'mini', label: '小程序', icon: '/static/icons/service-mini.png' },
{ id: 'app', label: 'APP', icon: '/static/icons/service-app.png' },
{ id: 'secondary', label: '二次开发', icon: '/static/icons/service-secondary.png' },
{ id: 'desktop', label: '桌面工具', icon: '/static/icons/service-desktop.png' },
],
contentTitle: '服务内容',
contentSections: [
'一、产品咨询与规划服务',
'1. 业务诊断与梳理:深入了解客户业务现状,梳理核心流程与痛点,输出业务诊断报告。',
'2. 竞品分析与市场调研:分析行业竞品功能与商业模式,为产品定位提供数据支撑。',
'3. 产品定位与MVP规划:明确产品核心价值,制定最小可行产品(MVP)路线图。',
'4. 技术可行性评估:评估技术实现路径、开发周期与成本,降低项目风险。',
'二、UI/UX 设计服务',
'1. 用户体验研究:通过用户访谈、可用性测试等方法,洞察用户需求与行为。',
'2. 交互原型设计:输出高保真交互原型,确保产品逻辑清晰、操作流畅。',
'3. 视觉界面设计:打造统一、美观、符合品牌调性的视觉设计体系。',
],
},
}
export function getBusinessDetail(id: string) {
return BUSINESS_DETAIL_MAP[id]
}

View File

@@ -0,0 +1,13 @@
export const BUSINESS_TYPE_OPTIONS = [
'互联网软件',
'硬件电路',
'物联网全案',
'生产制造',
]
export const BUSINESS_TYPE_DETAIL_ID: Record<string, string> = {
互联网软件: 'software',
硬件电路: 'hardware',
物联网全案: 'iot',
生产制造: 'production',
}

View File

@@ -0,0 +1,126 @@
import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
export const CASE_CATEGORIES: CaseCategory[] = [
'全部',
'互联网软件',
'硬件电路',
'物联网全案',
'生产制造',
]
export const CASE_PAGE_SIZE = 4
export const CASE_TOTAL_COUNT = 6
export const CASE_FILTER_INDUSTRIES = [
'金融科技',
'电商零售',
'教育培训',
'医疗健康',
'硬件电路',
'能源环保',
'房地产',
] as const
export const CASE_FILTER_SCALES = [
'全部',
'50万以下',
'50-100万',
'100-300万',
'300-500万',
'500万以上',
] as const
export const CASE_FILTER_TIME_RANGES = [
'全部',
'近3个月',
'近半年',
'近一年',
] as const
export const DEFAULT_CASE_FILTER: CaseFilterValues = {
industries: [],
scales: [],
timeRanges: [],
dateRange: {
start: '',
end: '',
},
}
export const CASE_LIBRARY_LIST: CaseItem[] = [
{
id: '1',
title: '智能园区综合管理平台开发',
category: '互联网软件',
cover: '/static/icons/background.png',
clientType: '企业',
industry: '房地产',
scale: '200万',
cycle: '200个工作日',
teamSize: '20人',
completedAt: '2026/8/10',
favorite: true,
},
{
id: '2',
title: '工业设备远程监控系统',
category: '物联网全案',
cover: '/static/icons/1.png',
clientType: '企业',
industry: '金融科技',
scale: '150万',
cycle: '180个工作日',
teamSize: '16人',
completedAt: '2026/7/22',
},
{
id: '3',
title: '社区康养智能服务系统',
category: '互联网软件',
cover: '/static/icons/software.png',
clientType: '政府',
industry: '医疗健康',
scale: '320万',
cycle: '240个工作日',
teamSize: '24人',
completedAt: '2026/6/15',
favorite: true,
},
{
id: '4',
title: '新能源充电桩控制主板设计',
category: '硬件电路',
cover: '/static/icons/hardware.png',
clientType: '企业',
industry: '能源环保',
scale: '80万',
cycle: '90个工作日',
teamSize: '8人',
completedAt: '2026/5/28',
},
{
id: '5',
title: '智慧农业环境监测平台',
category: '物联网全案',
cover: '/static/icons/iot.png',
clientType: '企业',
industry: '教育培训',
scale: '260万',
cycle: '210个工作日',
teamSize: '18人',
completedAt: '2026/4/12',
},
{
id: '6',
title: '智能穿戴设备批量生产项目',
category: '生产制造',
cover: '/static/icons/production.png',
clientType: '企业',
industry: '电商零售',
scale: '500万',
cycle: '300个工作日',
teamSize: '30人',
completedAt: '2026/3/8',
},
]

View File

@@ -0,0 +1,41 @@
import type { QuickEntryItem } from '@/types/home'
export const QUICK_ENTRY_MAX = 5
export const QUICK_ENTRY_STORAGE_KEY = 'home-quick-entry-ids'
export const DEFAULT_QUICK_ENTRY_IDS = [
'project',
'report',
'service',
'contract',
'finance',
]
export const QUICK_ENTRY_CATALOG: QuickEntryItem[] = [
{ id: 'project', label: '项目中心', icon: '/static/icons/quick-project.png', bgColor: '#E8F0FF' },
{ id: 'report', label: '快捷提报', icon: '/static/icons/quick-report.png', bgColor: '#E8FAF4' },
{ id: 'service', label: '在线客服', icon: '/static/icons/quick-service.png', bgColor: '#F3E8FF' },
{ id: 'contract', label: '合同管理', icon: '/static/icons/quick-contract.png', bgColor: '#FEE2E2' },
{ id: 'finance', label: '财务管理', icon: '/static/icons/quick-finance.png', bgColor: '#E0F2FE' },
{ id: 'personal', label: '个人中心', icon: '/static/icons/tab-personals.png', bgColor: '#E8F0FF' },
{ id: 'message', label: '消息中心', icon: '/static/icons/tab-messages.png', bgColor: '#F3E8FF' },
{ id: 'security', label: '账号安全', icon: '/static/icons/security.png', bgColor: '#E8FAF4' },
{ id: 'feedback', label: '意见反馈', icon: '/static/icons/feedback.png', bgColor: '#FFECE1' },
{ id: 'contract-list', label: '合同列表', icon: '/static/icons/contract-list.png', bgColor: '#FEE2E2' },
{ id: 'invoice', label: '发票列表', icon: '/static/icons/invoice.png', bgColor: '#E0F2FE' },
{ id: 'address', label: '地址管理', icon: '/static/icons/address.png', bgColor: '#E8F0FF' },
{ id: 'qualification', label: '资质列表', icon: '/static/icons/qualification.png', bgColor: '#FFF3EB' },
{ id: 'help', label: '帮助中心', icon: '/static/icons/help.png', bgColor: '#F3E8FF' },
{ id: 'about', label: '关于我们', icon: '/static/icons/about.png', bgColor: '#E8F0FF' },
]
export function getQuickEntryById(id: string) {
return QUICK_ENTRY_CATALOG.find(item => item.id === id)
}
export function mapQuickEntryIds(ids: string[]) {
return ids
.map(id => getQuickEntryById(id))
.filter((item): item is QuickEntryItem => Boolean(item))
}

View File

@@ -0,0 +1,71 @@
import { computed, ref } from 'vue'
import {
DEFAULT_QUICK_ENTRY_IDS,
mapQuickEntryIds,
QUICK_ENTRY_CATALOG,
QUICK_ENTRY_MAX,
QUICK_ENTRY_STORAGE_KEY,
} from '@/constants/quick-entries'
function loadPinnedIds(): string[] {
try {
const stored = uni.getStorageSync(QUICK_ENTRY_STORAGE_KEY)
if (Array.isArray(stored) && stored.length > 0)
return stored.slice(0, QUICK_ENTRY_MAX)
}
catch {
// ignore storage read errors
}
return [...DEFAULT_QUICK_ENTRY_IDS]
}
function savePinnedIds(ids: string[]) {
uni.setStorageSync(QUICK_ENTRY_STORAGE_KEY, ids.slice(0, QUICK_ENTRY_MAX))
}
export function useQuickEntries() {
const pinnedIds = ref<string[]>(loadPinnedIds())
const editorVisible = ref(false)
const pinnedEntries = computed(() => mapQuickEntryIds(pinnedIds.value))
const otherEntries = computed(() =>
QUICK_ENTRY_CATALOG.filter(item => !pinnedIds.value.includes(item.id)),
)
function openEditor() {
editorVisible.value = true
}
function closeEditor() {
editorVisible.value = false
}
function removeFromPinned(id: string) {
pinnedIds.value = pinnedIds.value.filter(itemId => itemId !== id)
savePinnedIds(pinnedIds.value)
}
function addToPinned(id: string) {
if (pinnedIds.value.includes(id))
return
if (pinnedIds.value.length >= QUICK_ENTRY_MAX) {
uni.showToast({ title: `最多添加${QUICK_ENTRY_MAX}个快捷入口`, icon: 'none' })
return
}
pinnedIds.value = [...pinnedIds.value, id]
savePinnedIds(pinnedIds.value)
}
return {
pinnedEntries,
otherEntries,
editorVisible,
openEditor,
closeEditor,
removeFromPinned,
addToPinned,
}
}

View File

@@ -49,6 +49,18 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/home-index/business-detail/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/home-index/requirement-submit/index",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/case-library/index", "path": "pages/case-library/index",
"style": { "style": {

View File

@@ -25,6 +25,10 @@ onLoad((options) => {
} }
if (type === 'loginFailed') { if (type === 'loginFailed') {
pageType.value = 'loginFailed' pageType.value = 'loginFailed'
return
}
if (type === 'submitSuccess') {
pageType.value = 'submitSuccess'
} }
}) })

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { CaseItem } from '@/types/case-library'
defineProps<{
item: CaseItem
}>()
const emit = defineEmits<{
toggleFavorite: [id: string]
}>()
</script>
<template>
<view class="case-card">
<view class="case-card__cover-wrap">
<image
class="case-card__cover"
:src="item.cover"
mode="aspectFill"
/>
<text class="case-card__tag">
{{ item.category }}
</text>
<view
class="case-card__favorite"
@tap.stop="emit('toggleFavorite', item.id)"
>
<image
class="case-card__favorite-icon"
:src="item.favorite ? '/static/icons/icon-heart-filled.svg' : '/static/icons/icon-heart.svg'"
mode="aspectFit"
/>
</view>
</view>
<text class="case-card__title">
{{ item.title }}
</text>
<view class="case-card__meta">
<view class="case-card__meta-row">
<view class="case-card__meta-item">
<text class="case-card__meta-label">
客户类型
</text>
<text class="case-card__meta-value">
{{ item.clientType }}
</text>
</view>
<view class="case-card__meta-item">
<text class="case-card__meta-label">
所属行业
</text>
<text class="case-card__meta-value">
{{ item.industry }}
</text>
</view>
<view class="case-card__meta-item">
<text class="case-card__meta-label">
规模
</text>
<text class="case-card__meta-value">
{{ item.scale }}
</text>
</view>
</view>
<view class="case-card__meta-row">
<view class="case-card__meta-item">
<text class="case-card__meta-label">
项目周期
</text>
<text class="case-card__meta-value">
{{ item.cycle }}
</text>
</view>
<view class="case-card__meta-item">
<text class="case-card__meta-label">
服务团队
</text>
<text class="case-card__meta-value">
{{ item.teamSize }}
</text>
</view>
<view class="case-card__meta-item">
<text class="case-card__meta-label">
完成时间
</text>
<text class="case-card__meta-value">
{{ item.completedAt }}
</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.case-card {
padding: 0 0 28rpx;
margin-bottom: 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
overflow: hidden;
}
.case-card__cover-wrap {
position: relative;
height: 320rpx;
overflow: hidden;
}
.case-card__cover {
width: 100%;
height: 100%;
}
.case-card__tag {
position: absolute;
top: 20rpx;
left: 20rpx;
padding: 6rpx 16rpx;
font-size: 22rpx;
color: #ffffff;
background: $color-primary;
border-radius: 8rpx;
}
.case-card__favorite {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
background: rgba(255, 255, 255, 0.92);
border-radius: 50%;
}
.case-card__favorite-icon {
width: 32rpx;
height: 32rpx;
}
.case-card__title {
display: block;
padding: 24rpx 24rpx 0;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.case-card__meta {
padding: 20rpx 24rpx 0;
}
.case-card__meta-row {
display: flex;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
.case-card__meta-item {
display: flex;
flex: 1;
flex-direction: column;
}
.case-card__meta-label {
font-size: 22rpx;
color: $color-text-secondary;
}
.case-card__meta-value {
margin-top: 8rpx;
font-size: 24rpx;
color: $color-text-primary;
}
</style>

View File

@@ -0,0 +1,526 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
CASE_FILTER_INDUSTRIES,
CASE_FILTER_SCALES,
CASE_FILTER_TIME_RANGES,
DEFAULT_CASE_FILTER,
} from '@/constants/case-library'
import type { CaseFilterValues, CaseItem } from '@/types/case-library'
import {
cloneCaseFilter,
getSelectedFilterTags,
matchCaseFilter,
removeFilterTag,
} from '@/utils/case-filter'
const props = defineProps<{
visible: boolean
cases: CaseItem[]
value: CaseFilterValues
}>()
const emit = defineEmits<{
close: []
confirm: [value: CaseFilterValues]
}>()
const draft = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
watch(
() => props.visible,
(visible) => {
if (visible)
draft.value = cloneCaseFilter(props.value)
},
)
const selectedTags = computed(() => getSelectedFilterTags(draft.value))
const previewCount = computed(() => {
return props.cases.filter(item => matchCaseFilter(item, draft.value)).length
})
function isIndustryActive(item: string) {
return draft.value.industries.includes(item)
}
function isScaleActive(item: string) {
if (item === '全部')
return draft.value.scales.length === 0
return draft.value.scales.includes(item)
}
function isTimeActive(item: string) {
if (item === '全部')
return draft.value.timeRanges.length === 0 && !draft.value.dateRange.start && !draft.value.dateRange.end
return draft.value.timeRanges.includes(item)
}
function toggleIndustry(item: string) {
const next = cloneCaseFilter(draft.value)
if (next.industries.includes(item))
next.industries = next.industries.filter(value => value !== item)
else
next.industries.push(item)
draft.value = next
}
function toggleScale(item: string) {
const next = cloneCaseFilter(draft.value)
if (item === '全部') {
next.scales = []
draft.value = next
return
}
if (next.scales.includes(item))
next.scales = next.scales.filter(value => value !== item)
else
next.scales.push(item)
draft.value = next
}
function toggleTime(item: string) {
const next = cloneCaseFilter(draft.value)
if (item === '全部') {
next.timeRanges = []
next.dateRange = { start: '', end: '' }
draft.value = next
return
}
if (next.timeRanges.includes(item))
next.timeRanges = next.timeRanges.filter(value => value !== item)
else
next.timeRanges.push(item)
draft.value = next
}
function handleDateChange(type: 'start' | 'end', value: string) {
const next = cloneCaseFilter(draft.value)
next.dateRange[type] = value.replace(/-/g, '/')
next.timeRanges = []
draft.value = next
}
function handleReset() {
draft.value = cloneCaseFilter(DEFAULT_CASE_FILTER)
}
function handleClearTags() {
draft.value = cloneCaseFilter(DEFAULT_CASE_FILTER)
}
function handleRemoveTag(key: string) {
draft.value = removeFilterTag(draft.value, key)
}
function handleCancel() {
emit('close')
}
function handleConfirm() {
emit('confirm', cloneCaseFilter(draft.value))
emit('close')
}
</script>
<template>
<view
v-if="visible"
class="case-filter-panel"
>
<view
class="case-filter-panel__mask"
@tap="handleCancel"
/>
<view
class="case-filter-panel__sheet"
@tap.stop
>
<view class="case-filter-panel__header">
<view class="case-filter-panel__title-wrap">
<image
class="case-filter-panel__title-icon"
src="/static/icons/icon-filter.svg"
mode="aspectFit"
/>
<text class="case-filter-panel__title">
筛选条件
</text>
</view>
<view
class="case-filter-panel__reset"
@tap="handleReset"
>
<text class="case-filter-panel__reset-icon">
↺
</text>
<text class="case-filter-panel__reset-text">
重置
</text>
</view>
</view>
<scroll-view
class="case-filter-panel__body"
scroll-y
:show-scrollbar="false"
>
<view class="case-filter-panel__section">
<text class="case-filter-panel__section-title">
行业
</text>
<view class="case-filter-panel__chips">
<text
v-for="item in CASE_FILTER_INDUSTRIES"
:key="item"
class="case-filter-panel__chip"
:class="{ 'case-filter-panel__chip--active': isIndustryActive(item) }"
@tap="toggleIndustry(item)"
>
{{ item }}
</text>
</view>
</view>
<view class="case-filter-panel__section">
<text class="case-filter-panel__section-title">
项目规模
</text>
<view class="case-filter-panel__chips">
<text
v-for="item in CASE_FILTER_SCALES"
:key="item"
class="case-filter-panel__chip"
:class="{ 'case-filter-panel__chip--active': isScaleActive(item) }"
@tap="toggleScale(item)"
>
{{ item }}
</text>
</view>
</view>
<view class="case-filter-panel__section">
<text class="case-filter-panel__section-title">
完成时间
</text>
<view class="case-filter-panel__chips">
<text
v-for="item in CASE_FILTER_TIME_RANGES"
:key="item"
class="case-filter-panel__chip"
:class="{ 'case-filter-panel__chip--active': isTimeActive(item) }"
@tap="toggleTime(item)"
>
{{ item }}
</text>
</view>
<view class="case-filter-panel__date-row">
<picker
mode="date"
:value="draft.dateRange.start.replace(/\//g, '-')"
@change="handleDateChange('start', $event.detail.value)"
>
<view class="case-filter-panel__date-field">
<text :class="{ 'case-filter-panel__date-placeholder': !draft.dateRange.start }">
{{ draft.dateRange.start || '开始时间' }}
</text>
</view>
</picker>
<text class="case-filter-panel__date-separator">
—
</text>
<picker
mode="date"
:value="draft.dateRange.end.replace(/\//g, '-')"
@change="handleDateChange('end', $event.detail.value)"
>
<view class="case-filter-panel__date-field">
<text :class="{ 'case-filter-panel__date-placeholder': !draft.dateRange.end }">
{{ draft.dateRange.end || '结束时间' }}
</text>
</view>
</picker>
</view>
</view>
</scroll-view>
<view
v-if="selectedTags.length > 0"
class="case-filter-panel__selected"
>
<view class="case-filter-panel__selected-header">
<text class="case-filter-panel__selected-title">
已选条件 ({{ selectedTags.length }}个)
</text>
<text
class="case-filter-panel__selected-clear"
@tap="handleClearTags"
>
清空
</text>
</view>
<view class="case-filter-panel__selected-tags">
<view
v-for="tag in selectedTags"
:key="tag.key"
class="case-filter-panel__tag"
>
<text class="case-filter-panel__tag-text">
{{ tag.label }}
</text>
<text
class="case-filter-panel__tag-close"
@tap.stop="handleRemoveTag(tag.key)"
>
×
</text>
</view>
</view>
</view>
<view class="case-filter-panel__footer">
<view
class="case-filter-panel__btn case-filter-panel__btn--cancel"
@tap="handleCancel"
>
取消
</view>
<view
class="case-filter-panel__btn case-filter-panel__btn--confirm"
@tap="handleConfirm"
>
确定 ({{ previewCount }}个结果)
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.case-filter-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 300;
}
.case-filter-panel__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.45);
}
.case-filter-panel__sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
max-height: 78vh;
background: $color-bg-page;
border-radius: 24rpx 24rpx 0 0;
}
.case-filter-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 28rpx 32rpx 20rpx;
}
.case-filter-panel__title-wrap,
.case-filter-panel__reset {
display: flex;
align-items: center;
}
.case-filter-panel__title-icon {
width: 28rpx;
height: 28rpx;
margin-right: 10rpx;
}
.case-filter-panel__title {
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.case-filter-panel__reset-icon {
margin-right: 6rpx;
font-size: 28rpx;
color: $color-primary;
}
.case-filter-panel__reset-text {
font-size: 26rpx;
color: $color-primary;
}
.case-filter-panel__body {
flex: 1;
min-height: 0;
padding: 0 32rpx;
box-sizing: border-box;
}
.case-filter-panel__section {
margin-bottom: 28rpx;
}
.case-filter-panel__section-title {
display: block;
margin-bottom: 20rpx;
font-size: 28rpx;
font-weight: 600;
color: $color-text-primary;
}
.case-filter-panel__chips {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.case-filter-panel__chip {
padding: 14rpx 24rpx;
font-size: 24rpx;
color: $color-text-primary;
background: $color-bg-section;
border-radius: 999rpx;
&--active {
color: $color-primary;
background: $color-primary-light;
}
}
.case-filter-panel__date-row {
display: flex;
align-items: center;
margin-top: 20rpx;
}
.case-filter-panel__date-field {
display: flex;
align-items: center;
justify-content: center;
min-width: 220rpx;
height: 72rpx;
padding: 0 20rpx;
background: $color-bg-section;
border-radius: 999rpx;
}
.case-filter-panel__date-placeholder {
color: $color-text-placeholder;
}
.case-filter-panel__date-separator {
margin: 0 16rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.case-filter-panel__selected {
flex-shrink: 0;
margin: 0 32rpx 20rpx;
padding: 20rpx 24rpx;
background: $color-bg-section;
border-radius: 16rpx;
}
.case-filter-panel__selected-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.case-filter-panel__selected-title {
font-size: 24rpx;
color: $color-text-secondary;
}
.case-filter-panel__selected-clear {
font-size: 24rpx;
color: $color-primary;
}
.case-filter-panel__selected-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.case-filter-panel__tag {
display: flex;
align-items: center;
padding: 8rpx 16rpx;
background: $color-primary-light;
border-radius: 999rpx;
}
.case-filter-panel__tag-text {
font-size: 22rpx;
color: $color-primary;
}
.case-filter-panel__tag-close {
margin-left: 8rpx;
font-size: 24rpx;
line-height: 1;
color: $color-primary;
}
.case-filter-panel__footer {
display: flex;
gap: 20rpx;
flex-shrink: 0;
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
}
.case-filter-panel__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
&--cancel {
color: $color-primary;
background: #ffffff;
border: 2rpx solid $color-primary;
}
&--confirm {
color: #ffffff;
background: $color-primary;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
defineProps<{
loading: boolean
hasMore: boolean
finished: boolean
}>()
const emit = defineEmits<{
loadMore: []
}>()
</script>
<template>
<view
v-if="hasMore || finished"
class="load-more-footer"
@tap="emit('loadMore')"
>
<view
v-if="loading"
class="load-more-footer__loading"
>
<view class="load-more-footer__spinner" />
<text class="load-more-footer__text">
加载更多...
</text>
</view>
<text
v-else-if="hasMore"
class="load-more-footer__text"
>
加载更多
</text>
<text
v-else-if="finished"
class="load-more-footer__finished"
>
没有更多了
</text>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.load-more-footer {
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx 0 16rpx;
}
.load-more-footer__loading {
display: flex;
align-items: center;
}
.load-more-footer__spinner {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
border: 4rpx solid #d6e4ff;
border-top-color: $color-primary;
border-radius: 50%;
animation: load-more-spin 0.8s linear infinite;
}
.load-more-footer__text,
.load-more-footer__finished {
font-size: 24rpx;
color: $color-text-secondary;
}
@keyframes load-more-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,41 +1,319 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppTabBar from '@/components/AppTabBar.vue' import AppTabBar from '@/components/AppTabBar.vue'
import {
CASE_CATEGORIES,
CASE_LIBRARY_LIST,
CASE_PAGE_SIZE,
DEFAULT_CASE_FILTER,
} from '@/constants/case-library'
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter'
import CaseCard from './components/CaseCard.vue'
import CaseFilterPanel from './components/CaseFilterPanel.vue'
import LoadMoreFooter from './components/LoadMoreFooter.vue'
const LOAD_MORE_DELAY = 600
const keyword = ref('')
const activeCategory = ref<CaseCategory>('全部')
const caseList = ref(CASE_LIBRARY_LIST.map(item => ({ ...item })))
const pageSize = ref(CASE_PAGE_SIZE)
const loadingMore = ref(false)
const filterVisible = ref(false)
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
const filteredCases = computed(() => {
const text = keyword.value.trim()
return caseList.value.filter((item) => {
const matchCategory = activeCategory.value === '全部' || item.category === activeCategory.value
const matchKeyword = !text || item.title.includes(text) || item.industry.includes(text) || item.category.includes(text)
const matchFilter = matchCaseFilter(item, appliedFilter.value)
return matchCategory && matchKeyword && matchFilter
})
})
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value))
const hasMore = computed(() => pageSize.value < filteredCases.value.length)
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0)
watch([keyword, activeCategory, appliedFilter], () => {
pageSize.value = CASE_PAGE_SIZE
})
function handleSelectCategory(category: CaseCategory) {
activeCategory.value = category
}
function handleToggleFavorite(id: string) {
caseList.value = caseList.value.map(item =>
item.id === id ? { ...item, favorite: !item.favorite } : item,
)
}
function handleLoadMore() {
if (loadingMore.value || !hasMore.value)
return
loadingMore.value = true
setTimeout(() => {
pageSize.value += CASE_PAGE_SIZE
loadingMore.value = false
}, LOAD_MORE_DELAY)
}
function handleOpenFilter() {
filterVisible.value = true
}
function handleCloseFilter() {
filterVisible.value = false
}
function handleConfirmFilter(value: CaseFilterValues) {
appliedFilter.value = cloneCaseFilter(value)
}
</script> </script>
<template> <template>
<view class="placeholder-page"> <view class="case-library">
<text class="placeholder-page__title"> <view class="case-library__header">
<text class="case-library__title">
案例库 案例库
</text> </text>
<text class="placeholder-page__desc">
功能开发中 <view class="case-library__search-row">
<view class="case-library__search">
<image
class="case-library__search-icon"
src="/static/icons/icon-search.svg"
mode="aspectFit"
/>
<input
v-model="keyword"
class="case-library__search-input"
placeholder="搜索案例标题、描述、标签..."
placeholder-class="case-library__search-placeholder"
>
</view>
<view class="case-library__favorite-btn">
<image
class="case-library__favorite-btn-icon"
src="/static/icons/icon-heart.svg"
mode="aspectFit"
/>
</view>
</view>
<scroll-view
class="case-library__tabs"
scroll-x
:show-scrollbar="false"
>
<view class="case-library__tabs-inner">
<text
v-for="category in CASE_CATEGORIES"
:key="category"
class="case-library__tab"
:class="{ 'case-library__tab--active': activeCategory === category }"
@tap="handleSelectCategory(category)"
>
{{ category }}
</text> </text>
</view>
</scroll-view>
<view class="case-library__toolbar">
<text class="case-library__count">
共 {{ filteredCases.length }}
</text>
<view
class="case-library__filter"
@tap="handleOpenFilter"
>
<image
class="case-library__filter-icon"
src="/static/icons/icon-filter.svg"
mode="aspectFit"
/>
<text class="case-library__filter-text">
筛选
</text>
</view>
</view>
</view>
<scroll-view
class="case-library__scroll"
scroll-y
:show-scrollbar="false"
:lower-threshold="120"
@scrolltolower="handleLoadMore"
>
<view class="case-library__list">
<CaseCard
v-for="item in visibleCases"
:key="item.id"
:item="item"
@toggle-favorite="handleToggleFavorite"
/>
<LoadMoreFooter
:loading="loadingMore"
:has-more="hasMore"
:finished="loadFinished"
@load-more="handleLoadMore"
/>
</view>
</scroll-view>
<AppTabBar active="case" /> <AppTabBar active="case" />
<CaseFilterPanel
:visible="filterVisible"
:cases="caseList"
:value="appliedFilter"
@close="handleCloseFilter"
@confirm="handleConfirmFilter"
/>
</view> </view>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/styles/variables.scss'; @import '@/styles/variables.scss';
.placeholder-page { .case-library {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; height: 100vh;
justify-content: center; overflow: hidden;
min-height: 100vh; background: $color-bg-section;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom)); }
.case-library__header {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 0;
background: $color-bg-page; background: $color-bg-page;
} }
.placeholder-page__title { .case-library__title {
display: block;
font-size: 36rpx; font-size: 36rpx;
font-weight: 600; font-weight: 600;
color: $color-text-primary; color: $color-text-primary;
text-align: center;
} }
.placeholder-page__desc { .case-library__search-row {
margin-top: 16rpx; display: flex;
align-items: center;
margin-top: 24rpx;
}
.case-library__search {
display: flex;
flex: 1;
align-items: center;
height: 72rpx;
padding: 0 24rpx;
background: $color-bg-section;
border-radius: 36rpx;
}
.case-library__search-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.case-library__search-input {
flex: 1;
font-size: 26rpx;
color: $color-text-primary;
}
.case-library__search-placeholder {
color: $color-text-placeholder;
}
.case-library__favorite-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
margin-left: 16rpx;
}
.case-library__favorite-btn-icon {
width: 40rpx;
height: 40rpx;
}
.case-library__tabs {
margin-top: 24rpx;
white-space: nowrap;
}
.case-library__tabs-inner {
display: inline-flex;
gap: 32rpx;
padding-right: 24rpx;
}
.case-library__tab {
flex-shrink: 0;
padding-bottom: 12rpx;
font-size: 28rpx; font-size: 28rpx;
color: $color-text-secondary; color: $color-text-secondary;
&--active {
font-weight: 600;
color: $color-text-primary;
border-bottom: 4rpx solid $color-primary;
}
}
.case-library__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0 24rpx;
}
.case-library__count {
font-size: 24rpx;
color: $color-text-secondary;
}
.case-library__filter {
display: flex;
align-items: center;
}
.case-library__filter-icon {
width: 28rpx;
height: 28rpx;
margin-right: 8rpx;
}
.case-library__filter-text {
font-size: 26rpx;
color: #666666;
}
.case-library__scroll {
flex: 1;
min-height: 0;
}
.case-library__list {
padding: 0 24rpx calc(140rpx + env(safe-area-inset-bottom));
} }
</style> </style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getBusinessDetail } from '@/constants/business-detail'
import type { BusinessDetailContent } from '@/types/business-detail'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import heroBg from '@/static/icons/background.png'
const detail = ref<BusinessDetailContent | null>(null)
onLoad((options) => {
const id = (options?.id as string) || 'software'
detail.value = getBusinessDetail(id) || null
if (!detail.value) {
uni.showToast({ title: '业务详情不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
})
function handleBack() {
navigateBackWithFallback('/pages/home-index/index')
}
function handleSubmit() {
if (!detail.value)
return
uni.navigateTo({
url: `/pages/home-index/requirement-submit/index?businessType=${encodeURIComponent(detail.value.title)}&detailId=${detail.value.id}`,
})
}
</script>
<template>
<view
v-if="detail"
class="business-detail"
>
<view class="business-detail__navbar">
<view
class="business-detail__back"
@tap="handleBack"
>
<image
class="business-detail__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="business-detail__navbar-title">
业务详情
</text>
<view class="business-detail__navbar-placeholder" />
</view>
<scroll-view
class="business-detail__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="business-detail__hero">
<image
class="business-detail__hero-bg"
:src="heroBg"
mode="scaleToFill"
/>
<view class="business-detail__hero-inner">
<image
class="business-detail__hero-icon"
:src="detail.icon"
mode="aspectFit"
/>
<text class="business-detail__hero-title">
{{ detail.title }}
</text>
<text class="business-detail__hero-subtitle">
{{ detail.subtitle }}
</text>
</view>
</view>
<view class="business-detail__section">
<text class="business-detail__section-title">
服务类型
</text>
<view class="business-detail__type-row">
<view
v-for="type in detail.serviceTypes"
:key="type.id"
class="business-detail__type-item"
>
<image
class="business-detail__type-icon"
:src="type.icon"
mode="aspectFit"
/>
<text class="business-detail__type-label">
{{ type.label }}
</text>
</view>
</view>
</view>
<view class="business-detail__section">
<text class="business-detail__section-title">
{{ detail.contentTitle }}
</text>
<view class="business-detail__video">
<view class="business-detail__video-play">
<text class="business-detail__video-play-icon">
▶
</text>
</view>
</view>
<view class="business-detail__content">
<text
v-for="(paragraph, index) in detail.contentSections"
:key="index"
class="business-detail__paragraph"
:class="{ 'business-detail__paragraph--title': paragraph.startsWith('一、') || paragraph.startsWith('二、') }"
>
{{ paragraph }}
</text>
</view>
</view>
</scroll-view>
<view class="business-detail__footer">
<view
class="business-detail__submit-btn"
@tap="handleSubmit"
>
立即提报
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.business-detail {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.business-detail__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 12rpx;
background: $color-bg-page;
}
.business-detail__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.business-detail__back-icon {
width: 40rpx;
height: 40rpx;
}
.business-detail__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.business-detail__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.business-detail__scroll {
flex: 1;
height: 0;
}
.business-detail__hero {
position: relative;
margin: 20rpx 24rpx 0;
overflow: hidden;
border-radius: 20rpx;
}
.business-detail__hero-bg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.business-detail__hero-inner {
position: relative;
z-index: 1;
padding: 24rpx 24rpx 28rpx;
}
.business-detail__hero-icon {
width: 96rpx;
height: 96rpx;
}
.business-detail__hero-title {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.business-detail__hero-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #333333;
}
.business-detail__section {
margin: 20rpx 24rpx 0;
padding: 28rpx 24rpx 32rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.business-detail__section-title {
display: block;
margin-bottom: 28rpx;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.business-detail__type-row {
display: flex;
justify-content: space-between;
}
.business-detail__type-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
}
.business-detail__type-icon {
width: 91rpx;
height: 91rpx;
}
.business-detail__type-label {
margin-top: 12rpx;
font-size: 22rpx;
color: $color-text-primary;
text-align: center;
}
.business-detail__video {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 360rpx;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border-radius: 16rpx;
}
.business-detail__video-play {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 50%;
}
.business-detail__video-play-icon {
margin-left: 6rpx;
font-size: 32rpx;
color: #ffffff;
}
.business-detail__content {
margin-top: 28rpx;
}
.business-detail__paragraph {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
line-height: 1.8;
color: $color-text-primary;
&--title {
margin-top: 8rpx;
margin-bottom: 12rpx;
font-weight: 600;
}
}
.business-detail__footer {
flex-shrink: 0;
padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.business-detail__submit-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 44rpx;
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
function handleQuickSubmit() {
uni.navigateTo({
url: '/pages/home-index/requirement-submit/index',
})
}
function handleCustomerService() {
uni.showToast({
title: '客服中心开发中',
icon: 'none',
})
}
</script>
<template>
<view class="home-floating-actions">
<view
class="home-floating-actions__item"
@tap="handleQuickSubmit"
>
<view class="home-floating-actions__icon home-floating-actions__icon--submit">
<text class="home-floating-actions__plus">
+
</text>
</view>
<text class="home-floating-actions__label">
快捷提报
</text>
</view>
<view
class="home-floating-actions__item"
@tap="handleCustomerService"
>
<view class="home-floating-actions__icon home-floating-actions__icon--service">
<image
class="home-floating-actions__service-icon"
src="/static/icons/icon-float-service.png"
mode="aspectFit"
/>
</view>
<text class="home-floating-actions__label">
客服中心
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.home-floating-actions {
position: fixed;
right: 24rpx;
bottom: calc(160rpx + env(safe-area-inset-bottom));
z-index: 120;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 20rpx;
}
.home-floating-actions__item {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 28rpx 0 12rpx;
background: #ffffff;
border-radius: 999rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.home-floating-actions__icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
&--submit {
background: #22c55e;
}
&--service {
background: #e8f0ff;
}
}
.home-floating-actions__plus {
font-size: 32rpx;
font-weight: 500;
line-height: 1;
color: #ffffff;
}
.home-floating-actions__service-icon {
width: 32rpx;
height: 32rpx;
}
.home-floating-actions__label {
margin-left: 16rpx;
font-size: 26rpx;
font-weight: 500;
color: $color-text-primary;
white-space: nowrap;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProjectItem } from '@/types/home' import type { ProjectItem } from '@/types/home'
import cardBg from '@/static/icons/new-card.png'
defineProps<{ defineProps<{
item: ProjectItem item: ProjectItem
@@ -8,6 +9,12 @@ defineProps<{
<template> <template>
<view class="project-card"> <view class="project-card">
<image
class="project-card__bg"
:src="cardBg"
mode="scaleToFill"
/>
<view class="project-card__inner">
<view class="project-card__header"> <view class="project-card__header">
<text class="project-card__title"> <text class="project-card__title">
{{ item.title }} {{ item.title }}
@@ -24,19 +31,15 @@ defineProps<{
> >
{{ item.category }} {{ item.category }}
</text> </text>
<view class="project-card__meta-info">
<text class="project-card__no"> <text class="project-card__no">
项目编号:{{ item.projectNo }} 项目编号:{{ item.projectNo }}
</text> </text>
</view>
<view class="project-card__progress-row">
<text class="project-card__progress-label">
进行中
</text>
<text class="project-card__progress-value"> <text class="project-card__progress-value">
{{ item.progress }}% {{ item.progress }}%
</text> </text>
</view> </view>
</view>
<view class="project-card__progress-bar"> <view class="project-card__progress-bar">
<view <view
@@ -54,17 +57,32 @@ defineProps<{
</text> </text>
</view> </view>
</view> </view>
</view>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/styles/variables.scss'; @import '@/styles/variables.scss';
.project-card { .project-card {
padding: 28rpx; position: relative;
margin-top: 20rpx; margin-top: 20rpx;
background: $color-bg-page; overflow: hidden;
border-radius: 20rpx; border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); }
.project-card__bg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.project-card__inner {
position: relative;
z-index: 1;
padding: 28rpx;
} }
.project-card__header { .project-card__header {
@@ -93,45 +111,52 @@ defineProps<{
margin-top: 16rpx; margin-top: 16rpx;
} }
.project-card__meta-info {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
min-width: 0;
margin-left: 12rpx;
}
.project-card__tag { .project-card__tag {
flex-shrink: 0; flex-shrink: 0;
padding: 4rpx 12rpx; padding: 4rpx 12rpx;
font-size: 20rpx; font-size: 20rpx;
border-radius: 6rpx; border-radius: 6rpx;
font-weight: 500;
width: 100rpx;
text-align: center;
height: 20rpx;
line-height: 20rpx;
&--orange { &--orange {
color: $color-tag-orange; color: #875045;
background: $color-tag-orange-bg; background: #FFF1EA;
}
&--peach {
color: #e07b4a;
background: #fff0e8;
} }
} }
.project-card__no { .project-card__no {
margin-left: 12rpx; flex: 1;
min-width: 0;
font-size: 22rpx; font-size: 22rpx;
color: $color-text-secondary; color: #666666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.project-card__progress-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
}
.project-card__progress-label,
.project-card__progress-value { .project-card__progress-value {
flex-shrink: 0;
margin-left: 12rpx;
font-size: 24rpx; font-size: 24rpx;
color: $color-text-secondary; color: #666666;
} }
.project-card__progress-bar { .project-card__progress-bar {
height: 12rpx; height: 12rpx;
margin-top: 12rpx; margin-top: 24rpx;
overflow: hidden; overflow: hidden;
background: #eef2ff; background: #eef2ff;
border-radius: 6rpx; border-radius: 6rpx;
@@ -152,11 +177,11 @@ defineProps<{
.project-card__time { .project-card__time {
font-size: 22rpx; font-size: 22rpx;
color: $color-text-secondary; color: #666666;
} }
.project-card__detail { .project-card__detail {
font-size: 24rpx; font-size: 24rpx;
color: $color-text-link; color: #666666;
} }
</style> </style>

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { QuickEntryItem } from '@/types/home'
defineProps<{
visible: boolean
pinnedEntries: QuickEntryItem[]
otherEntries: QuickEntryItem[]
}>()
const emit = defineEmits<{
close: []
remove: [id: string]
add: [id: string]
}>()
</script>
<template>
<view
v-if="visible"
class="quick-entry-editor"
>
<view
class="quick-entry-editor__mask"
@tap="emit('close')"
/>
<view
class="quick-entry-editor__panel"
@tap.stop
>
<view class="quick-entry-editor__header">
<text class="quick-entry-editor__title">
快捷入口
</text>
<text
class="quick-entry-editor__close"
@tap="emit('close')"
>
×
</text>
</view>
<scroll-view
class="quick-entry-editor__body"
scroll-y
:show-scrollbar="false"
>
<view class="quick-entry-editor__grid quick-entry-editor__grid--pinned">
<view
v-for="item in pinnedEntries"
:key="item.id"
class="quick-entry-editor__item"
>
<view class="quick-entry-editor__icon-wrap">
<image
class="quick-entry-editor__icon"
:src="item.icon"
mode="aspectFit"
/>
<view
class="quick-entry-editor__badge"
@tap.stop="emit('remove', item.id)"
>
<img src="@/static/icons/delete.png" alt="close">
</view>
</view>
<text class="quick-entry-editor__label">
{{ item.label }}
</text>
</view>
</view>
<text class="quick-entry-editor__section-title">
其他入口
</text>
<view class="quick-entry-editor__grid quick-entry-editor__grid--other">
<view
v-for="item in otherEntries"
:key="item.id"
class="quick-entry-editor__item"
>
<view class="quick-entry-editor__icon-wrap">
<image
class="quick-entry-editor__icon"
:src="item.icon"
mode="aspectFit"
/>
<view
class="quick-entry-editor__badge"
@tap.stop="emit('add', item.id)"
>
<img src="@/static/icons/add.png" alt="close">
</view>
</view>
<text class="quick-entry-editor__label">
{{ item.label }}
</text>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.quick-entry-editor {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 0 40rpx;
box-sizing: border-box;
}
.quick-entry-editor__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.45);
}
.quick-entry-editor__panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: 100%;
max-height: 80vh;
overflow: hidden;
background: $color-bg-page;
border-radius: 24rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
}
.quick-entry-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 32rpx 32rpx 24rpx;
}
.quick-entry-editor__title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.quick-entry-editor__close {
width: 48rpx;
font-size: 40rpx;
line-height: 1;
color: $color-text-secondary;
text-align: right;
}
.quick-entry-editor__body {
flex: 1;
max-height: calc(80vh - 100rpx);
padding: 0 24rpx 32rpx;
box-sizing: border-box;
}
.quick-entry-editor__section-title {
display: block;
margin: 32rpx 8rpx 24rpx;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.quick-entry-editor__grid {
display: flex;
flex-wrap: wrap;
&--pinned {
justify-content: space-between;
padding: 0 8rpx;
}
&--other {
row-gap: 28rpx;
padding: 0 8rpx;
}
}
.quick-entry-editor__item {
display: flex;
flex-direction: column;
align-items: center;
width: 20%;
}
.quick-entry-editor__icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
}
.quick-entry-editor__icon {
width: 72rpx;
height: 72rpx;
}
.quick-entry-editor__badge {
position: absolute;
top: -4rpx;
right: -4rpx;
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
background: #c8cdd4;
border-radius: 50%;
}
.quick-entry-editor__badge-text {
font-size: 22rpx;
font-weight: 600;
line-height: 1;
color: #ffffff;
}
.quick-entry-editor__label {
margin-top: 12rpx;
font-size: 22rpx;
color: $color-text-primary;
text-align: center;
}
</style>

View File

@@ -1,9 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ServiceItem } from '@/types/home' import type { ServiceItem } from '@/types/home'
defineProps<{ const props = defineProps<{
item: ServiceItem item: ServiceItem
}>() }>()
function handleTap() {
if (!props.item.detailId)
return
uni.navigateTo({
url: `/pages/home-index/business-detail/index?id=${props.item.detailId}`,
})
}
</script> </script>
<template> <template>
@@ -12,7 +21,9 @@ defineProps<{
:class="[ :class="[
`service-card--${item.theme}`, `service-card--${item.theme}`,
{ 'service-card--with-illus': item.illus }, { 'service-card--with-illus': item.illus },
{ 'service-card--clickable': item.detailId },
]" ]"
@tap="handleTap"
> >
<image <image
v-if="item.illus" v-if="item.illus"
@@ -63,6 +74,10 @@ defineProps<{
&--orange { &--orange {
background: linear-gradient( 180deg, #F7D3C6 0%, #FFFFFF 100%); background: linear-gradient( 180deg, #F7D3C6 0%, #FFFFFF 100%);
} }
&--clickable {
cursor: pointer;
}
} }
.service-card__illus { .service-card__illus {

View File

@@ -1,16 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import AppTabBar from '@/components/AppTabBar.vue' import AppTabBar from '@/components/AppTabBar.vue'
import { useGreeting } from '@/hooks/use-greeting' import { useGreeting } from '@/hooks/use-greeting'
import type { ProjectItem, QuickEntryItem, ServiceItem } from '@/types/home' import { useQuickEntries } from '@/hooks/use-quick-entries'
import type { ProjectItem, ServiceItem } from '@/types/home'
import headerBg from '@/static/icons/1.png' import headerBg from '@/static/icons/1.png'
import softwareIllus from '@/static/icons/software.png' import softwareIllus from '@/static/icons/software.png'
import hardwareIllus from '@/static/icons/hardware.png' import hardwareIllus from '@/static/icons/hardware.png'
import iotIllus from '@/static/icons/iot.png' import iotIllus from '@/static/icons/iot.png'
import productionIllus from '@/static/icons/production.png' import productionIllus from '@/static/icons/production.png'
import HomeFloatingActions from './components/HomeFloatingActions.vue'
import ProjectCard from './components/ProjectCard.vue' import ProjectCard from './components/ProjectCard.vue'
import QuickEntryEditor from './components/QuickEntryEditor.vue'
import ServiceCard from './components/ServiceCard.vue' import ServiceCard from './components/ServiceCard.vue'
const greeting = useGreeting() const greeting = useGreeting()
const {
pinnedEntries,
otherEntries,
editorVisible,
openEditor,
closeEditor,
removeFromPinned,
addToPinned,
} = useQuickEntries()
const services: ServiceItem[] = [ const services: ServiceItem[] = [
{ {
@@ -19,6 +31,7 @@ const services: ServiceItem[] = [
desc: 'Web/小程序/APP/二次开发/桌面工具等', desc: 'Web/小程序/APP/二次开发/桌面工具等',
theme: 'blue', theme: 'blue',
illus: softwareIllus, illus: softwareIllus,
detailId: 'software',
}, },
{ {
id: '2', id: '2',
@@ -43,14 +56,6 @@ const services: ServiceItem[] = [
}, },
] ]
const quickEntries: QuickEntryItem[] = [
{ id: '1', label: '项目中心', icon: '/static/icons/quick-project.png', bgColor: '#E8F0FF' },
{ id: '2', label: '快捷提报', icon: '/static/icons/quick-report.png', bgColor: '#E8FAF4' },
{ id: '3', label: '在线客服', icon: '/static/icons/quick-service.png', bgColor: '#F3E8FF' },
{ id: '4', label: '合同管理', icon: '/static/icons/quick-contract.png', bgColor: '#FEE2E2' },
{ id: '5', label: '财务管理', icon: '/static/icons/quick-finance.png', bgColor: '#E0F2FE' },
]
const projects: ProjectItem[] = [ const projects: ProjectItem[] = [
{ {
id: '1', id: '1',
@@ -70,7 +75,7 @@ const projects: ProjectItem[] = [
progress: 65, progress: 65,
status: '进行中', status: '进行中',
updateTime: '2026-8-13 11:04:12', updateTime: '2026-8-13 11:04:12',
tagTheme: 'peach', tagTheme: 'orange',
}, },
] ]
</script> </script>
@@ -146,13 +151,16 @@ const projects: ProjectItem[] = [
<text class="home__section-title"> <text class="home__section-title">
快捷入口 快捷入口
</text> </text>
<text class="home__section-action"> <text
class="home__section-action"
@tap="openEditor"
>
编辑 编辑
</text> </text>
</view> </view>
<view class="home__quick-row"> <view class="home__quick-row">
<view <view
v-for="entry in quickEntries" v-for="entry in pinnedEntries"
:key="entry.id" :key="entry.id"
class="home__quick-item" class="home__quick-item"
> >
@@ -168,7 +176,7 @@ const projects: ProjectItem[] = [
</view> </view>
</view> </view>
<view class="home__section home__section--last"> <view class="home__sectionss home__section--last">
<view class="home__section-header"> <view class="home__section-header">
<text class="home__section-title"> <text class="home__section-title">
在办项目 在办项目
@@ -180,17 +188,27 @@ const projects: ProjectItem[] = [
<view class="home__stats-row"> <view class="home__stats-row">
<view class="home__stat-card home__stat-card--blue"> <view class="home__stat-card home__stat-card--blue">
<text class="home__stat-value"> <view class="home__stat-value">
3 个项目 <text class="home__stat-num">
3
</text> </text>
<text class="home__stat-unit">
个项目
</text>
</view>
<text class="home__stat-label"> <text class="home__stat-label">
进行中 进行中
</text> </text>
</view> </view>
<view class="home__stat-card home__stat-card--orange"> <view class="home__stat-card home__stat-card--orange">
<text class="home__stat-value"> <view class="home__stat-value">
3 项待办 <text class="home__stat-num">
3
</text> </text>
<text class="home__stat-unit">
项待办
</text>
</view>
<text class="home__stat-label"> <text class="home__stat-label">
待您处理 待您处理
</text> </text>
@@ -211,6 +229,17 @@ const projects: ProjectItem[] = [
</scroll-view> </scroll-view>
<AppTabBar active="home" /> <AppTabBar active="home" />
<HomeFloatingActions />
<QuickEntryEditor
:visible="editorVisible"
:pinned-entries="pinnedEntries"
:other-entries="otherEntries"
@close="closeEditor"
@remove="removeFromPinned"
@add="addToPinned"
/>
</view> </view>
</template> </template>
@@ -361,6 +390,14 @@ const projects: ProjectItem[] = [
padding-top: 20rpx padding-top: 20rpx
} }
.home__sectionss {
margin-bottom: 20rpx;
width: 711.83rpx;
background: $color-bg-page;
border-radius: 20rpx;
padding-top: 20rpx
}
.home__section--last { .home__section--last {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -422,34 +459,52 @@ const projects: ProjectItem[] = [
.home__stats-row { .home__stats-row {
display: flex; display: flex;
gap: 20rpx; gap: 20rpx;
padding: 0 20rpx;
} }
.home__stat-card { .home__stat-card {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
padding: 28rpx 24rpx; align-items: center;
justify-content: center;
padding: 32rpx 24rpx;
border-radius: 16rpx; border-radius: 16rpx;
text-align: center;
&--blue { &--blue {
background: $color-primary-light; background: linear-gradient(180deg, #D3E1FC 0%, #FFFFFF 100%);
} }
&--orange { &--orange {
background: $color-stat-orange-bg; background: linear-gradient(180deg, #FFECE1 0%, #FFFFFF 100%);
} }
} }
.home__stat-value { .home__stat-value {
font-size: 32rpx; display: flex;
align-items: baseline;
justify-content: center;
}
.home__stat-num {
font-size: 56rpx;
font-weight: 700; font-weight: 700;
line-height: 1;
color: $color-text-primary;
}
.home__stat-unit {
margin-left: 4rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary; color: $color-text-primary;
} }
.home__stat-label { .home__stat-label {
margin-top: 8rpx; margin-top: 12rpx;
font-size: 24rpx; font-size: 24rpx;
color: $color-text-secondary; color: #333333;
} }
.home__recent-title { .home__recent-title {

View File

@@ -0,0 +1,706 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { BUSINESS_TYPE_DETAIL_ID, BUSINESS_TYPE_OPTIONS } from '@/constants/business-types'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import { navigateToResultPage } from '@/utils/navigate-result'
import deleteIcon from '@/static/icons/X.png'
import pdfIcon from '@/static/icons/PDF.png'
import pngIcon from '@/static/icons/PNG.png'
const businessType = ref('')
const detailId = ref('software')
const businessTypeDropdownVisible = ref(false)
const businessTypeOptions = BUSINESS_TYPE_OPTIONS
const projectName = ref('')
const description = ref('')
const budget = ref('')
const cycle = ref('')
const userName = ref('王永志')
const userPhone = ref('186****9618')
const remark = ref('')
const budgetOptions = ['1万以下', '1-5万', '5-10万', '10万+']
const attachments = ref([
{ id: '1', name: '需求文档.pdf', type: 'pdf', icon: pdfIcon },
{ id: '2', name: '参考图.png', type: 'png', icon: pdfIcon},
{ id: '3', name: '合同模板.pdf', type: 'pdf', icon: pngIcon },
])
const descLength = computed(() => description.value.length)
onLoad((options) => {
if (options?.detailId)
detailId.value = options.detailId as string
if (options?.businessType) {
businessType.value = decodeURIComponent(options.businessType as string)
if (!options.detailId)
detailId.value = BUSINESS_TYPE_DETAIL_ID[businessType.value] || 'software'
}
})
function handleBack() {
closeBusinessTypeDropdown()
navigateBackWithFallback(`/pages/home-index/business-detail/index?id=${detailId.value}`)
}
function toggleBusinessTypeDropdown() {
businessTypeDropdownVisible.value = !businessTypeDropdownVisible.value
}
function closeBusinessTypeDropdown() {
businessTypeDropdownVisible.value = false
}
function handleSelectBusinessType(option: string) {
businessType.value = option
closeBusinessTypeDropdown()
}
function handleSelectBudget(value: string) {
budget.value = value
}
function handleRemoveAttachment(id: string) {
attachments.value = attachments.value.filter(item => item.id !== id)
}
function handleAddAttachment() {
uni.showToast({ title: '附件上传开发中', icon: 'none' })
}
function handleSaveDraft() {
uni.showToast({ title: '草稿已保存', icon: 'none' })
}
function handleReset() {
projectName.value = ''
description.value = ''
budget.value = ''
cycle.value = ''
remark.value = ''
}
function handleSubmit() {
if (!businessType.value) {
uni.showToast({ title: '请选择业务类型', icon: 'none' })
return
}
if (!projectName.value.trim()) {
uni.showToast({ title: '项目名称不能为空', icon: 'none' })
return
}
if (!description.value.trim()) {
uni.showToast({ title: '请填写核心需求描述', icon: 'none' })
return
}
navigateToResultPage('submitSuccess')
}
</script>
<template>
<view class="requirement-submit">
<view class="requirement-submit__navbar">
<view
class="requirement-submit__back"
@tap="handleBack"
>
<image
class="requirement-submit__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="requirement-submit__navbar-title">
需求提报
</text>
<view class="requirement-submit__navbar-placeholder" />
</view>
<scroll-view
class="requirement-submit__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="requirement-submit__card requirement-submit__card--dropdown">
<view class="requirement-submit__field">
<text class="requirement-submit__label">
业务类型<text class="requirement-submit__required">
*
</text>
</text>
<view class="requirement-submit__select-wrap">
<view
class="requirement-submit__select"
@tap.stop="toggleBusinessTypeDropdown"
>
<text
class="requirement-submit__select-text"
:class="{ 'requirement-submit__select-text--placeholder': !businessType }"
>
{{ businessType || '选择下方推荐的服务或手动输入服务名称' }}
</text>
<text
class="requirement-submit__select-arrow"
:class="{ 'requirement-submit__select-arrow--open': businessTypeDropdownVisible }"
>
▾
</text>
</view>
<view
v-if="businessTypeDropdownVisible"
class="requirement-submit__dropdown"
@tap.stop
>
<view
v-for="option in businessTypeOptions"
:key="option"
class="requirement-submit__dropdown-item"
:class="{ 'requirement-submit__dropdown-item--active': businessType === option }"
@tap="handleSelectBusinessType(option)"
>
{{ option }}
</view>
</view>
</view>
</view>
</view>
<view class="requirement-submit__card">
<view class="requirement-submit__field">
<text class="requirement-submit__label">
项目名称<text class="requirement-submit__required">
*
</text>
</text>
<input
v-model="projectName"
class="requirement-submit__input requirement-submit__input--box"
placeholder="项目名称不能为空"
placeholder-class="requirement-submit__placeholder"
>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
核心需求描述<text class="requirement-submit__required">
*
</text>
</text>
<view class="requirement-submit__textarea-wrap">
<textarea
v-model="description"
class="requirement-submit__textarea"
placeholder="请详细描述您的核心需求,包括功能目标、使用场景、期望效果等"
placeholder-class="requirement-submit__placeholder"
maxlength="500"
/>
<text class="requirement-submit__counter">
{{ descLength }}/500字
</text>
</view>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
预算范围
</text>
<view class="requirement-submit__budget-row">
<view
v-for="option in budgetOptions"
:key="option"
class="requirement-submit__budget-item"
:class="{ 'requirement-submit__budget-item--active': budget === option }"
@tap="handleSelectBudget(option)"
>
{{ option }}
</view>
</view>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
预计开发周期
</text>
<view class="requirement-submit__cycle-row">
<input
v-model="cycle"
class="requirement-submit__input requirement-submit__input--box requirement-submit__input--cycle"
placeholder="请输入预计开发周期"
placeholder-class="requirement-submit__placeholder"
>
</view>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
用户名称
</text>
<input
v-model="userName"
class="requirement-submit__input requirement-submit__input--box"
placeholder="请输入用户名称"
placeholder-class="requirement-submit__placeholder"
>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
用户手机号
</text>
<input
v-model="userPhone"
class="requirement-submit__input requirement-submit__input--box"
placeholder="请输入用户手机号"
placeholder-class="requirement-submit__placeholder"
>
</view>
<view class="requirement-submit__field">
<text class="requirement-submit__label">
附件上传
</text>
<text class="requirement-submit__hint">
支持图片/文档/压缩包
</text>
<view class="requirement-submit__attachment-row">
<view
v-for="file in attachments"
:key="file.id"
class="requirement-submit__attachment-item"
>
<view class="requirement-submit__attachment-thumb">
<image
class="requirement-submit__attachment-icon"
:src="file.icon"
mode="aspectFit"
/>
</view>
<view
class="requirement-submit__attachment-remove"
@tap="handleRemoveAttachment(file.id)"
>
<image
class="requirement-submit__attachment-remove-icon"
:src="deleteIcon"
mode="aspectFit"
/>
</view>
</view>
<view
class="requirement-submit__attachment-add"
@tap="handleAddAttachment"
>
<text class="requirement-submit__attachment-add-icon">
+
</text>
<text class="requirement-submit__attachment-add-text">
添加附件
</text>
</view>
</view>
</view>
</view>
<view class="requirement-submit__card">
<view class="requirement-submit__field">
<text class="requirement-submit__label">
补充备注
</text>
<textarea
v-model="remark"
class="requirement-submit__textarea requirement-submit__textarea--remark"
placeholder="其他需要说明的事项..."
placeholder-class="requirement-submit__placeholder"
/>
</view>
</view>
</scroll-view>
<view
v-if="businessTypeDropdownVisible"
class="requirement-submit__dropdown-mask"
@tap="closeBusinessTypeDropdown"
/>
<view class="requirement-submit__footer">
<view
class="requirement-submit__footer-btn requirement-submit__footer-btn--primary"
@tap="handleSaveDraft"
>
保存草稿
</view>
<view
class="requirement-submit__footer-btn requirement-submit__footer-btn--primary"
@tap="handleReset"
>
重置
</view>
<view
class="requirement-submit__footer-btn requirement-submit__footer-btn--primary"
@tap="handleSubmit"
>
立即提报
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.requirement-submit {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.requirement-submit__navbar {
position: relative;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 12rpx;
background: $color-bg-page;
}
.requirement-submit__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.requirement-submit__back-icon {
width: 40rpx;
height: 40rpx;
}
.requirement-submit__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.requirement-submit__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.requirement-submit__scroll {
flex: 1;
height: 0;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.requirement-submit__card {
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
&--dropdown {
position: relative;
z-index: 20;
overflow: visible;
}
}
.requirement-submit__field {
margin-bottom: 28rpx;
&:last-child {
margin-bottom: 0;
}
}
.requirement-submit__label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.requirement-submit__required {
color: #ff4d4f;
}
.requirement-submit__hint {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.requirement-submit__input {
width: 100%;
font-size: 28rpx;
color: $color-text-primary;
&--box {
height: 80rpx;
padding: 0 24rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
box-sizing: border-box;
}
&--cycle {
flex: 1;
}
}
.requirement-submit__placeholder {
color: $color-text-placeholder;
}
.requirement-submit__select-wrap {
position: relative;
z-index: 20;
}
.requirement-submit__select {
display: flex;
align-items: center;
height: 80rpx;
padding: 0 24rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
.requirement-submit__select-text {
flex: 1;
font-size: 28rpx;
color: $color-text-primary;
&--placeholder {
color: $color-text-placeholder;
}
}
.requirement-submit__select-arrow {
flex-shrink: 0;
margin-left: 12rpx;
font-size: 24rpx;
color: $color-text-secondary;
transition: transform 0.2s ease;
&--open {
transform: rotate(180deg);
}
}
.requirement-submit__dropdown {
position: absolute;
top: calc(100% + 8rpx);
right: 0;
left: 0;
z-index: 21;
overflow: hidden;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
}
.requirement-submit__dropdown-item {
padding: 24rpx;
font-size: 28rpx;
color: $color-text-primary;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&--active {
color: $color-primary;
background: $color-primary-light;
}
}
.requirement-submit__dropdown-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
background: transparent;
}
.requirement-submit__textarea-wrap {
position: relative;
padding: 20rpx 24rpx 48rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
.requirement-submit__textarea {
width: 100%;
min-height: 180rpx;
font-size: 28rpx;
line-height: 1.6;
color: $color-text-primary;
&--remark {
min-height: 140rpx;
padding: 20rpx 24rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
box-sizing: border-box;
}
}
.requirement-submit__counter {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 22rpx;
color: $color-text-secondary;
}
.requirement-submit__budget-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.requirement-submit__budget-item {
padding: 14rpx 28rpx;
font-size: 26rpx;
color: $color-text-primary;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
&--active {
color: $color-primary;
background: $color-primary-light;
border-color: $color-primary-light;
}
}
.requirement-submit__cycle-row {
display: flex;
align-items: center;
}
.requirement-submit__cycle-unit {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 28rpx;
color: $color-text-primary;
}
.requirement-submit__attachment-row {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.requirement-submit__attachment-item {
position: relative;
width: 120rpx;
height: 120rpx;
}
.requirement-submit__attachment-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #f8f9fb;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
.requirement-submit__attachment-icon {
width: 72rpx;
height: 72rpx;
}
.requirement-submit__attachment-remove {
position: absolute;
top: -10rpx;
right: -10rpx;
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
}
.requirement-submit__attachment-remove-icon {
width: 32rpx;
height: 32rpx;
}
.requirement-submit__attachment-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
background: #f8f9fb;
border: 1rpx dashed #d8dde6;
border-radius: 12rpx;
}
.requirement-submit__attachment-add-icon {
font-size: 40rpx;
line-height: 1;
color: $color-text-secondary;
}
.requirement-submit__attachment-add-text {
margin-top: 8rpx;
font-size: 20rpx;
color: $color-text-secondary;
}
.requirement-submit__footer {
display: flex;
gap: 16rpx;
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.requirement-submit__footer-btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 80rpx;
font-size: 28rpx;
border-radius: 12rpx;
&--outline {
color: $color-primary;
background: $color-primary-light;
}
&--primary {
font-weight: 600;
color: #ffffff;
background: $color-primary;
}
}
</style>

BIN
src/static/icons/PDF.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
src/static/icons/PNG.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
src/static/icons/X.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

BIN
src/static/icons/about.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src/static/icons/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/static/icons/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/static/icons/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4H14M4 8H12M6 12H10" stroke="#2563EB" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 16.5L4.2 10.7C2.9 9.4 2.9 7.3 4.2 6C5.5 4.7 7.6 4.7 8.9 6L10 7.1L11.1 6C12.4 4.7 14.5 4.7 15.8 6C17.1 7.3 17.1 9.4 15.8 10.7L10 16.5Z" fill="#FF8A4C" stroke="#FF8A4C" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 16.5L4.2 10.7C2.9 9.4 2.9 7.3 4.2 6C5.5 4.7 7.6 4.7 8.9 6L10 7.1L11.1 6C12.4 4.7 14.5 4.7 15.8 6C17.1 7.3 17.1 9.4 15.8 10.7L10 16.5Z" stroke="#999999" stroke-width="1.6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="5.5" stroke="#999999" stroke-width="1.6"/>
<path d="M13.5 13.5L17 17" stroke="#999999" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#E8FAF4"/>
<path d="M18 30L22 18L26 26L30 22L32 30" stroke="#22C55E" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

View File

@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke="#999999" stroke-width="1.8"/>
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke="#999999" stroke-width="1.8"/>
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke="#999999" stroke-width="1.8"/>
<rect x="13" y="13" width="7" height="7" rx="1.5" stroke="#999999" stroke-width="1.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 10.5L12 4L20 10.5V19C20 19.5523 19.5523 20 19 20H5C4.44772 20 4 19.5523 4 19V10.5Z" stroke="#2563EB" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M9 20V13H15V20" stroke="#2563EB" stroke-width="1.8" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 6H19C19.5523 6 20 6.44772 20 7V15C20 15.5523 19.5523 16 19 16H9L5 19V7C5 6.44772 5.44772 6 6 6H5Z" stroke="#999999" stroke-width="1.8" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="4" stroke="#999999" stroke-width="1.8"/>
<path d="M5 19C5.67157 16.7909 8.13401 15 12 15C15.866 15 18.3284 16.7909 19 19" stroke="#999999" stroke-width="1.8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7C5 5.89543 5.89543 5 7 5H10L12 7H17C18.1046 7 19 7.89543 19 9V17C19 18.1046 18.1046 19 17 19H7C5.89543 19 5 18.1046 5 17V7Z" stroke="#999999" stroke-width="1.8" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 305 B

View File

@@ -0,0 +1,15 @@
export interface ServiceTypeItem {
id: string
label: string
icon: string
}
export interface BusinessDetailContent {
id: string
title: string
subtitle: string
icon: string
serviceTypes: ServiceTypeItem[]
contentTitle: string
contentSections: string[]
}

25
src/types/case-library.ts Normal file
View File

@@ -0,0 +1,25 @@
export type CaseCategory = '全部' | '互联网软件' | '硬件电路' | '物联网全案' | '生产制造'
export interface CaseItem {
id: string
title: string
category: Exclude<CaseCategory, '全部'>
cover: string
clientType: string
industry: string
scale: string
cycle: string
teamSize: string
completedAt: string
favorite?: boolean
}
export interface CaseFilterValues {
industries: string[]
scales: string[]
timeRanges: string[]
dateRange: {
start: string
end: string
}
}

View File

@@ -4,6 +4,7 @@ export interface ServiceItem {
desc: string desc: string
theme: 'blue' | 'green' | 'pink' | 'orange' theme: 'blue' | 'green' | 'pink' | 'orange'
illus?: string illus?: string
detailId?: string
} }
export interface QuickEntryItem { export interface QuickEntryItem {
@@ -21,5 +22,5 @@ export interface ProjectItem {
progress: number progress: number
status: string status: string
updateTime: string updateTime: string
tagTheme?: 'orange' | 'peach' tagTheme?: 'orange'
} }

View File

@@ -1,4 +1,4 @@
export type ResultPageType = 'register' | 'resetPassword' | 'loginFailed' export type ResultPageType = 'register' | 'resetPassword' | 'loginFailed' | 'submitSuccess'
export interface ResultPageConfig { export interface ResultPageConfig {
title: string title: string
@@ -26,6 +26,12 @@ export const RESULT_PAGE_CONFIG: Record<ResultPageType, ResultPageConfig> = {
icon: '/static/icons/icon-login-failed.svg', icon: '/static/icons/icon-login-failed.svg',
redirectUrl: '/pages/Login/user-register/index', redirectUrl: '/pages/Login/user-register/index',
}, },
submitSuccess: {
title: '提报成功',
subtitleTarget: '首页',
icon: '/static/icons/icon-register-success.png',
redirectUrl: '/pages/home-index/index',
},
} }
export const RESULT_PAGE_ROUTE = '/pages/Login/user-register-success/index' export const RESULT_PAGE_ROUTE = '/pages/Login/user-register-success/index'

147
src/utils/case-filter.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { CaseFilterValues, CaseItem } from '@/types/case-library'
function parseScaleValue(scale: string): number {
const match = scale.match(/(\d+)/)
return match ? Number(match[1]) : 0
}
function matchScaleRange(scale: string, range: string): boolean {
const value = parseScaleValue(scale)
switch (range) {
case '50万以下':
return value < 50
case '50-100万':
return value >= 50 && value <= 100
case '100-300万':
return value > 100 && value <= 300
case '300-500万':
return value > 300 && value <= 500
case '500万以上':
return value > 500
default:
return true
}
}
function parseDate(dateStr: string): Date | null {
if (!dateStr)
return null
const [year, month, day] = dateStr.split('/').map(Number)
if (!year || !month || !day)
return null
return new Date(year, month - 1, day)
}
function matchTimeRange(completedAt: string, range: string, now = new Date()): boolean {
const date = parseDate(completedAt)
if (!date)
return false
const monthsMap: Record<string, number> = {
近3个月: 3,
近半年: 6,
近一年: 12,
}
const months = monthsMap[range]
if (!months)
return true
const start = new Date(now)
start.setMonth(start.getMonth() - months)
return date >= start && date <= now
}
function matchDateRange(completedAt: string, start: string, end: string): boolean {
const date = parseDate(completedAt)
const startDate = parseDate(start)
const endDate = parseDate(end)
if (!date || !startDate || !endDate)
return true
return date >= startDate && date <= endDate
}
export function cloneCaseFilter(filter: CaseFilterValues): CaseFilterValues {
return {
industries: [...filter.industries],
scales: [...filter.scales],
timeRanges: [...filter.timeRanges],
dateRange: {
start: filter.dateRange.start,
end: filter.dateRange.end,
},
}
}
export function getSelectedFilterTags(filter: CaseFilterValues): Array<{ key: string, label: string }> {
const tags: Array<{ key: string, label: string }> = []
filter.industries.forEach((item) => {
tags.push({ key: `industry:${item}`, label: item })
})
filter.scales.forEach((item) => {
tags.push({ key: `scale:${item}`, label: item })
})
filter.timeRanges.forEach((item) => {
tags.push({ key: `time:${item}`, label: item })
})
if (filter.dateRange.start && filter.dateRange.end) {
tags.push({
key: 'date-range',
label: `${filter.dateRange.start}-${filter.dateRange.end}`,
})
}
return tags
}
export function removeFilterTag(filter: CaseFilterValues, key: string): CaseFilterValues {
const next = cloneCaseFilter(filter)
if (key.startsWith('industry:')) {
const value = key.replace('industry:', '')
next.industries = next.industries.filter(item => item !== value)
}
if (key.startsWith('scale:')) {
const value = key.replace('scale:', '')
next.scales = next.scales.filter(item => item !== value)
}
if (key.startsWith('time:')) {
const value = key.replace('time:', '')
next.timeRanges = next.timeRanges.filter(item => item !== value)
}
if (key === 'date-range') {
next.dateRange = { start: '', end: '' }
}
return next
}
export function matchCaseFilter(item: CaseItem, filter: CaseFilterValues): boolean {
if (filter.industries.length > 0 && !filter.industries.includes(item.industry))
return false
if (filter.scales.length > 0 && !filter.scales.some(range => matchScaleRange(item.scale, range)))
return false
if (filter.timeRanges.length > 0 && !filter.timeRanges.some(range => matchTimeRange(item.completedAt, range)))
return false
if (filter.dateRange.start && filter.dateRange.end) {
if (!matchDateRange(item.completedAt, filter.dateRange.start, filter.dateRange.end))
return false
}
return true
}

View File

@@ -0,0 +1,15 @@
export function navigateBackWithFallback(fallbackUrl: string) {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
uni.redirectTo({
url: fallbackUrl,
fail: () => {
uni.reLaunch({ url: fallbackUrl })
},
})
}