feat:首页和案例库功能
This commit is contained in:
10
src/api/banner.ts
Normal file
10
src/api/banner.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { BannerItem } from '@/types/banner'
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
const BANNER_API = {
|
||||
list: '/app/banner/list',
|
||||
}
|
||||
|
||||
export function getBannerList() {
|
||||
return get<BannerItem[]>(BANNER_API.list)
|
||||
}
|
||||
45
src/api/case-library.ts
Normal file
45
src/api/case-library.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
CaseLibraryFavoriteListQuery,
|
||||
CaseLibraryListQuery,
|
||||
CaseLibraryPageResult,
|
||||
ProjectCaseVo,
|
||||
} from '@/types/case-library-api'
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
const CASE_LIBRARY_API = {
|
||||
list: '/app/caseLibrary/list',
|
||||
detail: (id: string | number) => `/app/caseLibrary/${id}`,
|
||||
industries: '/app/caseLibrary/industries',
|
||||
favoriteList: '/app/caseLibrary/favorite/list',
|
||||
favorite: (id: string | number) => `/app/caseLibrary/${id}/favorite`,
|
||||
unfavorite: (id: string | number) => `/app/caseLibrary/${id}/unfavorite`,
|
||||
}
|
||||
|
||||
export function getCaseLibraryList(params: CaseLibraryListQuery) {
|
||||
return get<CaseLibraryPageResult>(CASE_LIBRARY_API.list, params)
|
||||
}
|
||||
|
||||
/** 查询案例详情:GET /app/caseLibrary/{id} */
|
||||
export function getCaseLibraryDetail(id: string | number) {
|
||||
return get<ProjectCaseVo>(CASE_LIBRARY_API.detail(id))
|
||||
}
|
||||
|
||||
/** 案例库行业选项:GET /app/caseLibrary/industries */
|
||||
export function getCaseLibraryIndustries() {
|
||||
return get<string[]>(CASE_LIBRARY_API.industries, undefined, { showErrorToast: false })
|
||||
}
|
||||
|
||||
/** 我的收藏列表:GET /app/caseLibrary/favorite/list */
|
||||
export function getCaseLibraryFavoriteList(params: CaseLibraryFavoriteListQuery) {
|
||||
return get<CaseLibraryPageResult>(CASE_LIBRARY_API.favoriteList, params)
|
||||
}
|
||||
|
||||
/** 收藏案例:POST /app/caseLibrary/{id}/favorite */
|
||||
export function favoriteCase(id: string | number) {
|
||||
return post<void>(CASE_LIBRARY_API.favorite(id), {})
|
||||
}
|
||||
|
||||
/** 取消收藏:POST /app/caseLibrary/{id}/unfavorite */
|
||||
export function unfavoriteCase(id: string | number) {
|
||||
return post<void>(CASE_LIBRARY_API.unfavorite(id), {})
|
||||
}
|
||||
14
src/api/oss.ts
Normal file
14
src/api/oss.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface OssFileItem {
|
||||
ossId?: number | string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function getOssListByIds(ossIds: Array<string | number>) {
|
||||
const ids = [...new Set(ossIds.map(id => String(id).trim()).filter(Boolean))]
|
||||
if (!ids.length)
|
||||
return Promise.resolve([] as OssFileItem[])
|
||||
|
||||
return get<OssFileItem[]>(`/resource/oss/listByIds/${ids.join(',')}`, undefined, {
|
||||
showErrorToast: false,
|
||||
})
|
||||
}
|
||||
16
src/api/project-submission.ts
Normal file
16
src/api/project-submission.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ProjectSubmissionItem } from '@/types/project-submission'
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
const PROJECT_SUBMISSION_API = {
|
||||
list: '/app/projectSubmission/list',
|
||||
detail: (id: string | number) => `/app/projectSubmission/${id}`,
|
||||
}
|
||||
|
||||
export function getProjectSubmissionList() {
|
||||
return get<ProjectSubmissionItem[]>(PROJECT_SUBMISSION_API.list)
|
||||
}
|
||||
|
||||
/** 查询 App 项目提报详情:GET /app/projectSubmission/{id} */
|
||||
export function getProjectSubmissionDetail(id: string | number) {
|
||||
return get<ProjectSubmissionItem>(PROJECT_SUBMISSION_API.detail(id))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { CASE_LIBRARY_LIST } from '@/constants/case-library'
|
||||
import type { CaseDetail } from '@/types/case-library'
|
||||
|
||||
const SHARED_BACKGROUND = '随着我国城镇化进程不断加快,园区管理面临着从传统粗放式管理向精细化、智能化管理转型的迫切需求。在"数字中国"战略背景下,各地园区纷纷探索通过信息化手段提升运营效率、优化资源配置、改善企业服务体验。'
|
||||
|
||||
const SHARED_PAIN_POINTS = '传统园区管理方式依赖人工巡检和纸质记录,信息孤岛严重,各部门数据无法互通,导致管理效率低下。企业服务响应慢,缺乏统一的服务入口,企业诉求难以快速传达和处理。能耗管理粗放,无法实时监控和分析,造成资源浪费。'
|
||||
|
||||
const SHARED_REQUIREMENT = '建设一套集园区管理、企业服务、数据分析于一体的综合管理平台,实现园区运营的数字化、智能化。平台需支持多端访问,包括Web管理端、移动端APP,满足不同角色的使用需求。'
|
||||
|
||||
const SHARED_DELIVERABLES = '交付园区综合管理平台一套,包含Web管理端、移动端APP、数据可视化大屏,并提供完整的源代码、技术文档、用户手册及一年免费运维服务。'
|
||||
|
||||
const DEFAULT_ATTACHMENTS = [
|
||||
{ id: '1', name: '需求文档.pdf', icon: '/static/icons/PDF.png' },
|
||||
{ id: '2', name: '系统....pdf', icon: '/static/icons/PDF.png' },
|
||||
{ id: '3', name: '参考图.png', icon: '/static/icons/PNG.png' },
|
||||
]
|
||||
|
||||
const CASE_DETAIL_MAP: Record<string, CaseDetail> = Object.fromEntries(
|
||||
CASE_LIBRARY_LIST.map(item => [
|
||||
item.id,
|
||||
{
|
||||
...item,
|
||||
background: SHARED_BACKGROUND,
|
||||
painPoints: SHARED_PAIN_POINTS,
|
||||
requirement: SHARED_REQUIREMENT,
|
||||
deliverables: SHARED_DELIVERABLES,
|
||||
attachments: DEFAULT_ATTACHMENTS,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
export function getCaseDetail(id: string): CaseDetail | null {
|
||||
return CASE_DETAIL_MAP[id] || null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
|
||||
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
|
||||
|
||||
export const CASE_CATEGORIES: CaseCategory[] = [
|
||||
'全部',
|
||||
@@ -10,17 +10,11 @@ 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_FAVORITE_LIST_SORT = {
|
||||
orderByColumn: 'completedAt',
|
||||
isAsc: 'desc',
|
||||
} as const
|
||||
|
||||
export const CASE_FILTER_SCALES = [
|
||||
'全部',
|
||||
@@ -47,80 +41,3 @@ export const DEFAULT_CASE_FILTER: CaseFilterValues = {
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,73 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { CASE_LIBRARY_LIST } from '@/constants/case-library'
|
||||
import type { CaseItem } from '@/types/case-library'
|
||||
|
||||
export const CASE_FAVORITES_STORAGE_KEY = 'case-favorites'
|
||||
|
||||
function getDefaultFavoriteIds(): string[] {
|
||||
return CASE_LIBRARY_LIST
|
||||
.filter(item => item.favorite)
|
||||
.map(item => item.id)
|
||||
}
|
||||
|
||||
function loadFavoriteIds(): string[] {
|
||||
try {
|
||||
const stored = uni.getStorageSync(CASE_FAVORITES_STORAGE_KEY)
|
||||
if (Array.isArray(stored))
|
||||
return stored
|
||||
}
|
||||
catch {
|
||||
// ignore storage read errors
|
||||
}
|
||||
|
||||
return getDefaultFavoriteIds()
|
||||
}
|
||||
|
||||
function saveFavoriteIds(ids: string[]) {
|
||||
uni.setStorageSync(CASE_FAVORITES_STORAGE_KEY, ids)
|
||||
}
|
||||
|
||||
const favoriteIds = ref<string[]>(loadFavoriteIds())
|
||||
import { favoriteCase, unfavoriteCase } from '@/api/case-library'
|
||||
import { ensureLoggedIn } from '@/utils/auth-guard'
|
||||
|
||||
export function useCaseFavorites() {
|
||||
const favoriteCases = computed(() =>
|
||||
CASE_LIBRARY_LIST
|
||||
.filter(item => favoriteIds.value.includes(item.id))
|
||||
.map(item => ({ ...item, favorite: true })),
|
||||
)
|
||||
async function toggleFavorite(id: string, currentFavorite: boolean) {
|
||||
if (!ensureLoggedIn())
|
||||
return false
|
||||
|
||||
function syncFavoriteIds() {
|
||||
favoriteIds.value = loadFavoriteIds()
|
||||
try {
|
||||
if (currentFavorite)
|
||||
await unfavoriteCase(id)
|
||||
else
|
||||
await favoriteCase(id)
|
||||
return true
|
||||
}
|
||||
|
||||
function isFavorite(id: string) {
|
||||
return favoriteIds.value.includes(id)
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
|
||||
function toggleFavorite(id: string) {
|
||||
if (favoriteIds.value.includes(id)) {
|
||||
favoriteIds.value = favoriteIds.value.filter(itemId => itemId !== id)
|
||||
}
|
||||
else {
|
||||
favoriteIds.value = [...favoriteIds.value, id]
|
||||
}
|
||||
|
||||
saveFavoriteIds(favoriteIds.value)
|
||||
}
|
||||
|
||||
function applyFavoritesToList(list: CaseItem[]): CaseItem[] {
|
||||
return list.map(item => ({
|
||||
...item,
|
||||
favorite: favoriteIds.value.includes(item.id),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
favoriteIds,
|
||||
favoriteCases,
|
||||
syncFavoriteIds,
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
applyFavoritesToList,
|
||||
}
|
||||
}
|
||||
|
||||
34
src/hooks/use-case-industries.ts
Normal file
34
src/hooks/use-case-industries.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
import { getCaseLibraryIndustries } from '@/api/case-library'
|
||||
|
||||
const industries = ref<string[]>([])
|
||||
let loading = false
|
||||
let loaded = false
|
||||
|
||||
export function useCaseIndustries() {
|
||||
async function loadCaseIndustries(force = false) {
|
||||
if ((loaded || loading) && !force)
|
||||
return industries.value
|
||||
|
||||
loading = true
|
||||
try {
|
||||
const data = await getCaseLibraryIndustries()
|
||||
industries.value = Array.isArray(data) ? data.filter(Boolean) : []
|
||||
loaded = true
|
||||
}
|
||||
catch {
|
||||
if (!industries.value.length)
|
||||
industries.value = []
|
||||
}
|
||||
finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
return industries.value
|
||||
}
|
||||
|
||||
return {
|
||||
industries,
|
||||
loadCaseIndustries,
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,12 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/home-index/project-submission-detail/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/home-index/requirement-submit/index",
|
||||
"style": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { loginByPassword } from '@/api/auth'
|
||||
import { setAccessToken, setLoginPhoneNumber } from '@/utils/auth-storage'
|
||||
import { saveAuthSession, setLoginPhoneNumber } from '@/utils/auth-storage'
|
||||
import { navigateToResultPage } from '@/utils/navigate-result'
|
||||
import { validatePhone } from '@/utils/sms-code'
|
||||
import LoginInput from './components/LoginInput.vue'
|
||||
@@ -44,7 +44,7 @@ async function handleLogin() {
|
||||
}, { showErrorToast: false })
|
||||
|
||||
if (result.access_token)
|
||||
setAccessToken(result.access_token)
|
||||
saveAuthSession(result.access_token, result.client_id)
|
||||
|
||||
setLoginPhoneNumber(phone.value.trim())
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { loginBySms } from '@/api/auth'
|
||||
import { setAccessToken, setLoginPhoneNumber } from '@/utils/auth-storage'
|
||||
import { saveAuthSession, setLoginPhoneNumber } from '@/utils/auth-storage'
|
||||
import { navigateBackWithFallback } from '@/utils/navigate-back'
|
||||
import { navigateToResultPage } from '@/utils/navigate-result'
|
||||
import { validatePhone } from '@/utils/sms-code'
|
||||
@@ -46,7 +46,7 @@ async function handleLogin() {
|
||||
}, { showErrorToast: false })
|
||||
|
||||
if (result.access_token)
|
||||
setAccessToken(result.access_token)
|
||||
saveAuthSession(result.access_token, result.client_id)
|
||||
|
||||
setLoginPhoneNumber(phone.value.trim())
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
CASE_FILTER_INDUSTRIES,
|
||||
CASE_FILTER_SCALES,
|
||||
CASE_FILTER_TIME_RANGES,
|
||||
DEFAULT_CASE_FILTER,
|
||||
@@ -20,6 +19,7 @@ const props = defineProps<{
|
||||
visible: boolean
|
||||
cases: CaseFilterTarget[]
|
||||
value: CaseFilterValues
|
||||
industries: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -185,7 +185,7 @@ function handleConfirm() {
|
||||
</text>
|
||||
<view class="case-filter-panel__chips">
|
||||
<text
|
||||
v-for="item in CASE_FILTER_INDUSTRIES"
|
||||
v-for="item in industries"
|
||||
:key="item"
|
||||
class="case-filter-panel__chip"
|
||||
:class="{ 'case-filter-panel__chip--active': isIndustryActive(item) }"
|
||||
|
||||
@@ -115,13 +115,21 @@
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.case-detail__image-placeholder {
|
||||
.case-detail__cover {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: #e8eaed;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.case-detail__rich-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.case-detail__paragraph {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getCaseDetail } from '@/constants/case-detail'
|
||||
import { getCaseLibraryDetail } from '@/api/case-library'
|
||||
import type { CaseDetail } from '@/types/case-library'
|
||||
import { mapProjectCaseToCaseDetail } from '@/utils/map-case-library'
|
||||
import { navigateBackWithFallback } from '@/utils/navigate-back'
|
||||
import deleteIcon from '@/static/icons/X.png'
|
||||
|
||||
const detail = ref<CaseDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const overviewItems = [
|
||||
{ label: '客户类型', key: 'clientType' as const },
|
||||
@@ -17,20 +18,47 @@ const overviewItems = [
|
||||
{ label: '完成时间', key: 'completedAt' as const },
|
||||
]
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
loading.value = true
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
|
||||
try {
|
||||
const data = await getCaseLibraryDetail(id)
|
||||
detail.value = await mapProjectCaseToCaseDetail(data)
|
||||
}
|
||||
catch {
|
||||
detail.value = null
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
navigateBackWithFallback('/pages/case-library/index')
|
||||
}, 1500)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const rawId = options?.id
|
||||
const id = rawId ? decodeURIComponent(String(rawId)) : '1'
|
||||
detail.value = getCaseDetail(id)
|
||||
|
||||
if (!detail.value) {
|
||||
uni.showToast({ title: '案例不存在', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
const id = rawId ? decodeURIComponent(String(rawId)) : ''
|
||||
if (!id) {
|
||||
loading.value = false
|
||||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadDetail(id)
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
navigateBackWithFallback('/pages/case-library/index')
|
||||
}
|
||||
|
||||
function handleOpenAttachment(url?: string) {
|
||||
if (!url)
|
||||
return
|
||||
uni.previewImage({ urls: [url] })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,40 +118,40 @@ function handleBack() {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="case-detail__section">
|
||||
<view
|
||||
v-for="(section, index) in detail.catalogs"
|
||||
:key="section.id"
|
||||
class="case-detail__section"
|
||||
:class="{ 'case-detail__section--last': index === detail.catalogs.length - 1 && !detail.attachments.length }"
|
||||
>
|
||||
<text class="case-detail__section-title">
|
||||
项目背景
|
||||
{{ section.title }}
|
||||
</text>
|
||||
<view class="case-detail__image-placeholder" />
|
||||
<text class="case-detail__paragraph">
|
||||
{{ detail.background }}
|
||||
<image
|
||||
v-if="index === 0 && detail.cover"
|
||||
class="case-detail__cover"
|
||||
:src="detail.cover"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<rich-text
|
||||
v-if="section.content"
|
||||
class="case-detail__rich-text"
|
||||
:nodes="section.content"
|
||||
/>
|
||||
<text
|
||||
v-else
|
||||
class="case-detail__paragraph"
|
||||
>
|
||||
—
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="case-detail__section">
|
||||
<view
|
||||
v-if="detail.attachments.length"
|
||||
class="case-detail__section case-detail__section--last"
|
||||
>
|
||||
<text class="case-detail__section-title">
|
||||
客户痛点
|
||||
</text>
|
||||
<text class="case-detail__paragraph">
|
||||
{{ detail.painPoints }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="case-detail__section">
|
||||
<text class="case-detail__section-title">
|
||||
需求描述
|
||||
</text>
|
||||
<text class="case-detail__paragraph">
|
||||
{{ detail.requirement }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="case-detail__section case-detail__section--last">
|
||||
<text class="case-detail__section-title">
|
||||
交付成果
|
||||
</text>
|
||||
<text class="case-detail__paragraph">
|
||||
{{ detail.deliverables }}
|
||||
附件
|
||||
</text>
|
||||
<scroll-view
|
||||
class="case-detail__attachments"
|
||||
@@ -135,14 +163,8 @@ function handleBack() {
|
||||
v-for="file in detail.attachments"
|
||||
:key="file.id"
|
||||
class="case-detail__attachment"
|
||||
@tap="handleOpenAttachment(file.url)"
|
||||
>
|
||||
<view class="case-detail__attachment-remove">
|
||||
<image
|
||||
class="case-detail__attachment-remove-icon"
|
||||
:src="deleteIcon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<image
|
||||
class="case-detail__attachment-icon"
|
||||
:src="file.icon"
|
||||
@@ -158,8 +180,29 @@ function handleBack() {
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="!loading"
|
||||
class="case-detail case-detail--empty"
|
||||
>
|
||||
<text class="case-detail__empty-text">
|
||||
暂无详情
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './index.scss';
|
||||
|
||||
.case-detail--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.case-detail__empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #999999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { getCaseLibraryFavoriteList } from '@/api/case-library'
|
||||
import {
|
||||
CASE_CATEGORIES,
|
||||
CASE_FILTER_INDUSTRIES,
|
||||
CASE_FAVORITE_LIST_SORT,
|
||||
CASE_FILTER_SCALES,
|
||||
CASE_FILTER_TIME_RANGES,
|
||||
CASE_PAGE_SIZE,
|
||||
DEFAULT_CASE_FILTER,
|
||||
} from '@/constants/case-library'
|
||||
import { useCaseFavorites } from '@/hooks/use-case-favorites'
|
||||
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
|
||||
import { useCaseIndustries } from '@/hooks/use-case-industries'
|
||||
import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
|
||||
import filterIcon from '@/static/icons/icon-filter.svg'
|
||||
import { ensureLoggedIn } from '@/utils/auth-guard'
|
||||
import { navigateBackWithFallback } from '@/utils/navigate-back'
|
||||
import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter'
|
||||
import { mapProjectCasesToCaseItems } from '@/utils/map-case-library'
|
||||
import CaseCard from '../components/CaseCard.vue'
|
||||
import CaseFilterPanel from '../components/CaseFilterPanel.vue'
|
||||
import LoadMoreFooter from '../components/LoadMoreFooter.vue'
|
||||
|
||||
const LOAD_MORE_DELAY = 600
|
||||
|
||||
const { favoriteCases, syncFavoriteIds, toggleFavorite } = useCaseFavorites()
|
||||
const { toggleFavorite } = useCaseFavorites()
|
||||
const { industries, loadCaseIndustries } = useCaseIndustries()
|
||||
|
||||
const keyword = ref('')
|
||||
const activeCategory = ref<CaseCategory>('全部')
|
||||
const caseList = ref(favoriteCases.value.map(item => ({ ...item })))
|
||||
const pageSize = ref(CASE_PAGE_SIZE)
|
||||
const caseList = ref<CaseItem[]>([])
|
||||
const totalCount = ref(0)
|
||||
const pageNum = ref(1)
|
||||
const loadingList = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const filterVisible = ref(false)
|
||||
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
|
||||
@@ -41,19 +46,57 @@ const filteredCases = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value))
|
||||
const hasMore = computed(() => caseList.value.length < totalCount.value)
|
||||
const loadFinished = computed(() => !hasMore.value && caseList.value.length > 0)
|
||||
|
||||
const hasMore = computed(() => pageSize.value < filteredCases.value.length)
|
||||
async function fetchFavoriteList(reset = false) {
|
||||
if (reset) {
|
||||
pageNum.value = 1
|
||||
caseList.value = []
|
||||
}
|
||||
|
||||
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0)
|
||||
if (loadingList.value || loadingMore.value)
|
||||
return
|
||||
|
||||
watch([keyword, activeCategory, appliedFilter], () => {
|
||||
pageSize.value = CASE_PAGE_SIZE
|
||||
if (reset)
|
||||
loadingList.value = true
|
||||
else
|
||||
loadingMore.value = true
|
||||
|
||||
try {
|
||||
const result = await getCaseLibraryFavoriteList({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: CASE_PAGE_SIZE,
|
||||
orderByColumn: CASE_FAVORITE_LIST_SORT.orderByColumn,
|
||||
isAsc: CASE_FAVORITE_LIST_SORT.isAsc,
|
||||
})
|
||||
const rows = result?.rows ?? []
|
||||
const mapped = await mapProjectCasesToCaseItems(rows)
|
||||
const favorites = mapped.map(item => ({ ...item, favorite: true }))
|
||||
|
||||
totalCount.value = Number(result?.total ?? 0)
|
||||
caseList.value = reset
|
||||
? favorites
|
||||
: [...caseList.value, ...favorites]
|
||||
}
|
||||
catch {
|
||||
if (reset) {
|
||||
caseList.value = []
|
||||
totalCount.value = 0
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingList.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
syncFavoriteIds()
|
||||
caseList.value = favoriteCases.value.map(item => ({ ...item }))
|
||||
if (!ensureLoggedIn())
|
||||
return
|
||||
|
||||
loadCaseIndustries()
|
||||
fetchFavoriteList(true)
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
@@ -64,21 +107,21 @@ function handleSelectCategory(category: CaseCategory) {
|
||||
activeCategory.value = category
|
||||
}
|
||||
|
||||
function handleToggleFavorite(id: string) {
|
||||
toggleFavorite(id)
|
||||
caseList.value = favoriteCases.value.map(item => ({ ...item }))
|
||||
async function handleToggleFavorite(id: string) {
|
||||
const success = await toggleFavorite(id, true)
|
||||
if (!success)
|
||||
return
|
||||
|
||||
caseList.value = caseList.value.filter(item => item.id !== id)
|
||||
totalCount.value = Math.max(0, totalCount.value - 1)
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
if (loadingMore.value || !hasMore.value)
|
||||
return
|
||||
|
||||
loadingMore.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
pageSize.value += CASE_PAGE_SIZE
|
||||
loadingMore.value = false
|
||||
}, LOAD_MORE_DELAY)
|
||||
pageNum.value += 1
|
||||
fetchFavoriteList(false)
|
||||
}
|
||||
|
||||
function handleOpenFilter() {
|
||||
@@ -100,10 +143,15 @@ function handleCaseTap(id: string) {
|
||||
}
|
||||
|
||||
function showIndustryPicker() {
|
||||
if (!industries.value.length) {
|
||||
uni.showToast({ title: '暂无行业选项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showActionSheet({
|
||||
itemList: [...CASE_FILTER_INDUSTRIES],
|
||||
itemList: [...industries.value],
|
||||
success: ({ tapIndex }) => {
|
||||
const industry = CASE_FILTER_INDUSTRIES[tapIndex]
|
||||
const industry = industries.value[tapIndex]
|
||||
const next = cloneCaseFilter(appliedFilter.value)
|
||||
next.industries = next.industries.includes(industry)
|
||||
? next.industries.filter(item => item !== industry)
|
||||
@@ -250,7 +298,7 @@ function showTimePicker() {
|
||||
|
||||
<view class="case-favorites__toolbar">
|
||||
<text class="case-favorites__count">
|
||||
共 {{ filteredCases.length }}
|
||||
共 {{ totalCount }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -264,7 +312,7 @@ function showTimePicker() {
|
||||
>
|
||||
<view class="case-favorites__list">
|
||||
<CaseCard
|
||||
v-for="item in visibleCases"
|
||||
v-for="item in filteredCases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
@@ -272,7 +320,7 @@ function showTimePicker() {
|
||||
/>
|
||||
|
||||
<LoadMoreFooter
|
||||
:loading="loadingMore"
|
||||
:loading="loadingList || loadingMore"
|
||||
:has-more="hasMore"
|
||||
:finished="loadFinished"
|
||||
@load-more="handleLoadMore"
|
||||
@@ -283,6 +331,7 @@ function showTimePicker() {
|
||||
<CaseFilterPanel
|
||||
:visible="filterVisible"
|
||||
:cases="caseList"
|
||||
:industries="industries"
|
||||
:value="appliedFilter"
|
||||
@close="handleCloseFilter"
|
||||
@confirm="handleConfirmFilter"
|
||||
|
||||
@@ -1,69 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { getCaseLibraryList } from '@/api/case-library'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import { useCaseFavorites } from '@/hooks/use-case-favorites'
|
||||
import { useCaseIndustries } from '@/hooks/use-case-industries'
|
||||
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 type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
|
||||
import { ensureLoggedIn } from '@/utils/auth-guard'
|
||||
import { buildCaseLibraryQuery } from '@/utils/build-case-library-query'
|
||||
import { cloneCaseFilter } from '@/utils/case-filter'
|
||||
import { mapProjectCasesToCaseItems } from '@/utils/map-case-library'
|
||||
import filterIcon from '@/static/icons/icon-filter.svg'
|
||||
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 { applyFavoritesToList, syncFavoriteIds, toggleFavorite } = useCaseFavorites()
|
||||
const { toggleFavorite } = useCaseFavorites()
|
||||
const { industries, loadCaseIndustries } = useCaseIndustries()
|
||||
|
||||
const keyword = ref('')
|
||||
const activeCategory = ref<CaseCategory>('全部')
|
||||
const caseList = ref(applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item }))))
|
||||
const pageSize = ref(CASE_PAGE_SIZE)
|
||||
const caseList = ref<CaseItem[]>([])
|
||||
const totalCount = ref(0)
|
||||
const pageNum = ref(1)
|
||||
const loadingList = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const filterVisible = ref(false)
|
||||
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
|
||||
|
||||
const filteredCases = computed(() => {
|
||||
const text = keyword.value.trim()
|
||||
const hasMore = computed(() => caseList.value.length < totalCount.value)
|
||||
const loadFinished = computed(() => !hasMore.value && caseList.value.length > 0)
|
||||
|
||||
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
|
||||
})
|
||||
let keywordTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function fetchCaseList(reset = false) {
|
||||
if (reset) {
|
||||
pageNum.value = 1
|
||||
caseList.value = []
|
||||
}
|
||||
|
||||
if (loadingList.value || loadingMore.value)
|
||||
return
|
||||
|
||||
if (reset)
|
||||
loadingList.value = true
|
||||
else
|
||||
loadingMore.value = true
|
||||
|
||||
try {
|
||||
const params = buildCaseLibraryQuery({
|
||||
keyword: keyword.value,
|
||||
activeCategory: activeCategory.value,
|
||||
filter: appliedFilter.value,
|
||||
pageNum: pageNum.value,
|
||||
pageSize: CASE_PAGE_SIZE,
|
||||
})
|
||||
|
||||
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value))
|
||||
const result = await getCaseLibraryList(params)
|
||||
const rows = result?.rows ?? []
|
||||
const mapped = await mapProjectCasesToCaseItems(rows)
|
||||
|
||||
const hasMore = computed(() => pageSize.value < filteredCases.value.length)
|
||||
totalCount.value = Number(result?.total ?? 0)
|
||||
caseList.value = reset
|
||||
? mapped
|
||||
: [...caseList.value, ...mapped]
|
||||
}
|
||||
catch {
|
||||
if (reset) {
|
||||
caseList.value = []
|
||||
totalCount.value = 0
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingList.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0)
|
||||
watch(keyword, () => {
|
||||
if (keywordTimer)
|
||||
clearTimeout(keywordTimer)
|
||||
keywordTimer = setTimeout(() => {
|
||||
fetchCaseList(true)
|
||||
}, 400)
|
||||
})
|
||||
|
||||
watch([keyword, activeCategory, appliedFilter], () => {
|
||||
pageSize.value = CASE_PAGE_SIZE
|
||||
watch(activeCategory, () => {
|
||||
fetchCaseList(true)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
syncFavoriteIds()
|
||||
caseList.value = applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item })))
|
||||
loadCaseIndustries()
|
||||
fetchCaseList(true)
|
||||
})
|
||||
|
||||
function handleSelectCategory(category: CaseCategory) {
|
||||
activeCategory.value = category
|
||||
}
|
||||
|
||||
function handleToggleFavorite(id: string) {
|
||||
toggleFavorite(id)
|
||||
caseList.value = applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item })))
|
||||
async function handleToggleFavorite(id: string) {
|
||||
if (!ensureLoggedIn())
|
||||
return
|
||||
|
||||
const target = caseList.value.find(item => item.id === id)
|
||||
if (!target)
|
||||
return
|
||||
|
||||
const nextFavorite = !target.favorite
|
||||
caseList.value = caseList.value.map(item => (
|
||||
item.id === id ? { ...item, favorite: nextFavorite } : item
|
||||
))
|
||||
|
||||
const success = await toggleFavorite(id, target.favorite ?? false)
|
||||
if (!success) {
|
||||
caseList.value = caseList.value.map(item => (
|
||||
item.id === id ? { ...item, favorite: target.favorite } : item
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenFavorites() {
|
||||
if (!ensureLoggedIn())
|
||||
return
|
||||
|
||||
uni.navigateTo({
|
||||
url: '/pages/case-library/favorites/index',
|
||||
})
|
||||
@@ -73,12 +137,8 @@ function handleLoadMore() {
|
||||
if (loadingMore.value || !hasMore.value)
|
||||
return
|
||||
|
||||
loadingMore.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
pageSize.value += CASE_PAGE_SIZE
|
||||
loadingMore.value = false
|
||||
}, LOAD_MORE_DELAY)
|
||||
pageNum.value += 1
|
||||
fetchCaseList(false)
|
||||
}
|
||||
|
||||
function handleOpenFilter() {
|
||||
@@ -91,6 +151,7 @@ function handleCloseFilter() {
|
||||
|
||||
function handleConfirmFilter(value: CaseFilterValues) {
|
||||
appliedFilter.value = cloneCaseFilter(value)
|
||||
fetchCaseList(true)
|
||||
}
|
||||
|
||||
function handleCaseTap(id: string) {
|
||||
@@ -153,7 +214,7 @@ function handleCaseTap(id: string) {
|
||||
|
||||
<view class="case-library__toolbar">
|
||||
<text class="case-library__count">
|
||||
共 {{ filteredCases.length }}
|
||||
共 {{ totalCount }}
|
||||
</text>
|
||||
<view
|
||||
class="case-library__filter"
|
||||
@@ -180,7 +241,7 @@ function handleCaseTap(id: string) {
|
||||
>
|
||||
<view class="case-library__list">
|
||||
<CaseCard
|
||||
v-for="item in visibleCases"
|
||||
v-for="item in caseList"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
@@ -188,7 +249,7 @@ function handleCaseTap(id: string) {
|
||||
/>
|
||||
|
||||
<LoadMoreFooter
|
||||
:loading="loadingMore"
|
||||
:loading="loadingList || loadingMore"
|
||||
:has-more="hasMore"
|
||||
:finished="loadFinished"
|
||||
@load-more="handleLoadMore"
|
||||
@@ -201,6 +262,7 @@ function handleCaseTap(id: string) {
|
||||
<CaseFilterPanel
|
||||
:visible="filterVisible"
|
||||
:cases="caseList"
|
||||
:industries="industries"
|
||||
:value="appliedFilter"
|
||||
@close="handleCloseFilter"
|
||||
@confirm="handleConfirmFilter"
|
||||
|
||||
23
src/pages/home-index/components/HomeBanner.scss
Normal file
23
src/pages/home-index/components/HomeBanner.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
.home-banner {
|
||||
width: 100%;
|
||||
margin-bottom: 20rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.home-banner__swiper {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
}
|
||||
|
||||
.home-banner__item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-banner__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
40
src/pages/home-index/components/HomeBanner.vue
Normal file
40
src/pages/home-index/components/HomeBanner.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { BannerItem } from '@/types/banner'
|
||||
|
||||
defineProps<{
|
||||
list: BannerItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="list.length"
|
||||
class="home-banner"
|
||||
>
|
||||
<swiper
|
||||
class="home-banner__swiper"
|
||||
circular
|
||||
autoplay
|
||||
:interval="4000"
|
||||
indicator-dots
|
||||
indicator-color="rgba(255, 255, 255, 0.45)"
|
||||
indicator-active-color="#ffffff"
|
||||
>
|
||||
<swiper-item
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="home-banner__item"
|
||||
>
|
||||
<image
|
||||
class="home-banner__image"
|
||||
:src="item.imageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './HomeBanner.scss';
|
||||
</style>
|
||||
@@ -6,6 +6,13 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
function handleTap() {
|
||||
if (props.item.submissionId) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/home-index/project-submission-detail/index?id=${props.item.submissionId}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.item.detailId)
|
||||
return
|
||||
|
||||
@@ -21,7 +28,7 @@ function handleTap() {
|
||||
:class="[
|
||||
`service-card--${item.theme}`,
|
||||
{ 'service-card--with-illus': item.illus },
|
||||
{ 'service-card--clickable': item.detailId },
|
||||
{ 'service-card--clickable': item.detailId || item.submissionId },
|
||||
]"
|
||||
@tap="handleTap"
|
||||
>
|
||||
|
||||
@@ -128,11 +128,14 @@
|
||||
|
||||
.home__section {
|
||||
margin-bottom: 20rpx;
|
||||
height: 600rpx;
|
||||
width: 711.83rpx;
|
||||
min-height: 320rpx;
|
||||
height: auto;
|
||||
background: $color-bg-page;
|
||||
border-radius: 20rpx;
|
||||
padding-top: 20rpx
|
||||
padding-top: 20rpx;
|
||||
padding-bottom: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.home__sections {
|
||||
@@ -186,6 +189,14 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home__service-empty {
|
||||
display: block;
|
||||
padding: 48rpx 20rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home__quick-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { getBannerList } from '@/api/banner'
|
||||
import { getProjectSubmissionList } from '@/api/project-submission'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import { useGreeting } from '@/hooks/use-greeting'
|
||||
import { useQuickEntries } from '@/hooks/use-quick-entries'
|
||||
import type { BannerItem } from '@/types/banner'
|
||||
import type { ProjectItem, QuickEntryItem, ServiceItem } from '@/types/home'
|
||||
import { ensureLoggedIn } from '@/utils/auth-guard'
|
||||
import { mapProjectSubmissionsToServices } from '@/utils/map-project-submission'
|
||||
import { navigateQuickEntry } from '@/utils/quick-entry-navigate'
|
||||
import headerBg from '@/static/icons/1.png'
|
||||
import softwareIllus from '@/static/icons/software.png'
|
||||
import hardwareIllus from '@/static/icons/hardware.png'
|
||||
import iotIllus from '@/static/icons/iot.png'
|
||||
import productionIllus from '@/static/icons/production.png'
|
||||
import HomeBanner from './components/HomeBanner.vue'
|
||||
import HomeFloatingActions from './components/HomeFloatingActions.vue'
|
||||
import ProjectCard from './components/ProjectCard.vue'
|
||||
import QuickEntryEditor from './components/QuickEntryEditor.vue'
|
||||
@@ -27,41 +29,37 @@ const {
|
||||
addToPinned,
|
||||
} = useQuickEntries()
|
||||
|
||||
onShow(() => {
|
||||
ensureLoggedIn()
|
||||
})
|
||||
const banners = ref<BannerItem[]>([])
|
||||
const services = ref<ServiceItem[]>([])
|
||||
|
||||
const services: ServiceItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '互联网软件',
|
||||
desc: 'Web/小程序/APP/二次开发/桌面工具等',
|
||||
theme: 'blue',
|
||||
illus: softwareIllus,
|
||||
detailId: 'software',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '硬件电路',
|
||||
desc: '原理图设计/PCB设计/电气校验等',
|
||||
theme: 'green',
|
||||
illus: hardwareIllus,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '物联网全案',
|
||||
desc: '软硬件开发+云端一体化/定制化开发等',
|
||||
theme: 'pink',
|
||||
illus: iotIllus,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '生产制造',
|
||||
desc: 'PCB制版/SMT/整机装配/定制生产等',
|
||||
theme: 'orange',
|
||||
illus: productionIllus,
|
||||
},
|
||||
]
|
||||
async function loadBanners() {
|
||||
try {
|
||||
const list = await getBannerList()
|
||||
banners.value = (list ?? [])
|
||||
.filter(item => item.enabled === 1 || item.enabled === undefined)
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
}
|
||||
catch {
|
||||
banners.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectSubmissions() {
|
||||
try {
|
||||
const list = await getProjectSubmissionList()
|
||||
services.value = await mapProjectSubmissionsToServices(list ?? [])
|
||||
}
|
||||
catch {
|
||||
services.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
if (!ensureLoggedIn())
|
||||
return
|
||||
loadBanners()
|
||||
loadProjectSubmissions()
|
||||
})
|
||||
|
||||
function handleQuickEntryTap(entry: QuickEntryItem) {
|
||||
navigateQuickEntry(entry.id, entry.label)
|
||||
@@ -148,17 +146,28 @@ const projects: ProjectItem[] = [
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="home__content">
|
||||
<HomeBanner :list="banners" />
|
||||
|
||||
<view class="home__section">
|
||||
<text class="home__section-title">
|
||||
业务服务
|
||||
</text>
|
||||
<view class="home__service-grid">
|
||||
<view
|
||||
v-if="services.length"
|
||||
class="home__service-grid"
|
||||
>
|
||||
<ServiceCard
|
||||
v-for="item in services"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
/>
|
||||
</view>
|
||||
<text
|
||||
v-else
|
||||
class="home__service-empty"
|
||||
>
|
||||
暂无业务服务
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="home__sections">
|
||||
|
||||
188
src/pages/home-index/project-submission-detail/index.vue
Normal file
188
src/pages/home-index/project-submission-detail/index.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { getProjectSubmissionDetail } from '@/api/project-submission'
|
||||
import type { ProjectSubmissionItem, ProjectServiceType } from '@/types/project-submission'
|
||||
import { ensureLoggedIn } from '@/utils/auth-guard'
|
||||
import { navigateBackWithFallback } from '@/utils/navigate-back'
|
||||
import { resolveProjectSubmissionMedia } from '@/utils/resolve-project-submission-media'
|
||||
import heroBg from '@/static/icons/background.png'
|
||||
|
||||
const detail = ref<ProjectSubmissionItem | null>(null)
|
||||
const loading = ref(true)
|
||||
const heroLogo = ref('')
|
||||
const serviceIcons = ref<Record<string, string>>({})
|
||||
|
||||
const sortedServiceTypes = computed<ProjectServiceType[]>(() => {
|
||||
if (!detail.value?.serviceTypes?.length)
|
||||
return []
|
||||
return [...detail.value.serviceTypes].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
)
|
||||
})
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
loading.value = true
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
|
||||
try {
|
||||
const data = await getProjectSubmissionDetail(id)
|
||||
detail.value = data
|
||||
|
||||
const media = await resolveProjectSubmissionMedia(data)
|
||||
heroLogo.value = media.logoUrl || ''
|
||||
serviceIcons.value = media.serviceIcons
|
||||
}
|
||||
catch {
|
||||
detail.value = null
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const id = options?.id as string
|
||||
if (!id) {
|
||||
loading.value = false
|
||||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadDetail(id)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
ensureLoggedIn()
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
navigateBackWithFallback('/pages/home-index/index')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!detail.value)
|
||||
return
|
||||
|
||||
const businessType = detail.value.businessType || detail.value.title
|
||||
uni.navigateTo({
|
||||
url: `/pages/home-index/requirement-submit/index?submissionId=${detail.value.id}&businessType=${encodeURIComponent(businessType)}`,
|
||||
})
|
||||
}
|
||||
</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
|
||||
v-if="heroLogo"
|
||||
class="business-detail__hero-icon"
|
||||
:src="heroLogo"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="business-detail__hero-title">
|
||||
{{ detail.title }}
|
||||
</text>
|
||||
<text
|
||||
v-if="detail.subtitle"
|
||||
class="business-detail__hero-subtitle"
|
||||
>
|
||||
{{ detail.subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="sortedServiceTypes.length"
|
||||
class="business-detail__section"
|
||||
>
|
||||
<text class="business-detail__section-title">
|
||||
服务类型
|
||||
</text>
|
||||
<view class="business-detail__type-row">
|
||||
<view
|
||||
v-for="type in sortedServiceTypes"
|
||||
:key="type.id"
|
||||
class="business-detail__type-item"
|
||||
>
|
||||
<image
|
||||
v-if="serviceIcons[String(type.id)]"
|
||||
class="business-detail__type-icon"
|
||||
:src="serviceIcons[String(type.id)]"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="business-detail__type-label">
|
||||
{{ type.serviceName }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="business-detail__footer">
|
||||
<view
|
||||
class="business-detail__submit-btn"
|
||||
@tap="handleSubmit"
|
||||
>
|
||||
立即提报
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="!loading"
|
||||
class="business-detail business-detail--empty"
|
||||
>
|
||||
<text class="business-detail__empty-text">
|
||||
暂无详情
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../business-detail/index.scss';
|
||||
|
||||
.business-detail--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.business-detail__empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #999999;
|
||||
}
|
||||
</style>
|
||||
7
src/types/banner.ts
Normal file
7
src/types/banner.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface BannerItem {
|
||||
id: number
|
||||
imageUrl: string
|
||||
sort?: number
|
||||
enabled?: number
|
||||
updateTime?: string | null
|
||||
}
|
||||
68
src/types/case-library-api.ts
Normal file
68
src/types/case-library-api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface CaseLibraryPageQuery {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
orderByColumn?: string
|
||||
isAsc?: string
|
||||
}
|
||||
|
||||
export interface CaseLibraryListQuery extends CaseLibraryPageQuery {
|
||||
keyword?: string
|
||||
businessType?: string
|
||||
industry?: string
|
||||
scaleMin?: number
|
||||
scaleMax?: number
|
||||
completedAtBegin?: string
|
||||
completedAtEnd?: string
|
||||
timeRange?: string
|
||||
}
|
||||
|
||||
export interface CaseLibraryFavoriteListQuery extends CaseLibraryPageQuery {
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ProjectCaseAttachmentVo {
|
||||
id?: number | string
|
||||
projectId?: number | string
|
||||
ossId?: string
|
||||
originalName?: string
|
||||
fileSize?: string
|
||||
url?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ProjectCaseCatalogVo {
|
||||
id?: number | string
|
||||
projectId?: number | string
|
||||
title?: string
|
||||
content?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ProjectCaseVo {
|
||||
id: number | string
|
||||
caseNo?: string
|
||||
caseName?: string
|
||||
businessType?: string
|
||||
businessIndustry?: string
|
||||
industry?: string | null
|
||||
enabled?: number
|
||||
scale?: number | string
|
||||
previewCount?: number
|
||||
viewCount?: number
|
||||
favoriteCount?: number
|
||||
favorited?: boolean | null
|
||||
coverUrl?: string | null
|
||||
customerType?: string | null
|
||||
projectCycle?: string | number | null
|
||||
serviceTeam?: number | null
|
||||
completedAt?: string | null
|
||||
attachments?: ProjectCaseAttachmentVo[]
|
||||
catalogs?: ProjectCaseCatalogVo[]
|
||||
updateTime?: string | null
|
||||
}
|
||||
|
||||
export interface CaseLibraryPageResult {
|
||||
total: number
|
||||
rows: ProjectCaseVo[]
|
||||
}
|
||||
@@ -18,13 +18,17 @@ export interface CaseAttachment {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface CaseCatalogSection {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface CaseDetail extends CaseItem {
|
||||
background: string
|
||||
painPoints: string
|
||||
requirement: string
|
||||
deliverables: string
|
||||
catalogs: CaseCatalogSection[]
|
||||
attachments: CaseAttachment[]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface ServiceItem {
|
||||
theme: 'blue' | 'green' | 'pink' | 'orange'
|
||||
illus?: string
|
||||
detailId?: string
|
||||
/** App 项目提报 id,跳转提报详情 */
|
||||
submissionId?: string
|
||||
}
|
||||
|
||||
export interface QuickEntryItem {
|
||||
|
||||
19
src/types/project-submission.ts
Normal file
19
src/types/project-submission.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface ProjectServiceType {
|
||||
id?: number | string
|
||||
projectId?: number | string
|
||||
serviceName?: string
|
||||
iconUrl?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ProjectSubmissionItem {
|
||||
id: number | string
|
||||
businessType?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
enabled?: number
|
||||
coverUrl?: string
|
||||
logoUrl?: string
|
||||
serviceTypes?: ProjectServiceType[]
|
||||
updateTime?: string | null
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getAccessToken } from '@/utils/auth-storage'
|
||||
import { clearAuthSession, getAccessToken } from '@/utils/auth-storage'
|
||||
|
||||
export const LOGIN_PAGE = '/pages/Login/user-login/index'
|
||||
|
||||
let redirectingToLogin = false
|
||||
|
||||
export function isLoggedIn() {
|
||||
return Boolean(getAccessToken())
|
||||
}
|
||||
@@ -14,6 +16,26 @@ export function ensureLoggedIn(showToast = true) {
|
||||
if (showToast)
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
|
||||
uni.reLaunch({ url: LOGIN_PAGE })
|
||||
redirectToLogin()
|
||||
return false
|
||||
}
|
||||
|
||||
/** 登录态失效(如接口返回 401)时清除会话并跳转登录页 */
|
||||
export function handleUnauthorized(message = '登录已过期,请重新登录') {
|
||||
if (redirectingToLogin)
|
||||
return
|
||||
|
||||
redirectingToLogin = true
|
||||
clearAuthSession()
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
redirectToLogin()
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
uni.reLaunch({
|
||||
url: LOGIN_PAGE,
|
||||
complete: () => {
|
||||
redirectingToLogin = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { AUTH_CLIENT_ID } from '@/constants/config'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token'
|
||||
const AUTH_CLIENT_ID_KEY = 'auth_client_id'
|
||||
const LOGIN_PHONE_KEY = 'login_phone_number'
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
uni.setStorageSync(ACCESS_TOKEN_KEY, token)
|
||||
uni.setStorageSync(ACCESS_TOKEN_KEY, String(token).trim())
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return uni.getStorageSync(ACCESS_TOKEN_KEY) as string
|
||||
const token = uni.getStorageSync(ACCESS_TOKEN_KEY) as string
|
||||
return token ? String(token).trim() : ''
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
uni.removeStorageSync(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setAuthClientId(clientId: string) {
|
||||
uni.setStorageSync(AUTH_CLIENT_ID_KEY, String(clientId).trim())
|
||||
}
|
||||
|
||||
export function getAuthClientId() {
|
||||
const stored = uni.getStorageSync(AUTH_CLIENT_ID_KEY) as string
|
||||
return stored ? String(stored).trim() : AUTH_CLIENT_ID
|
||||
}
|
||||
|
||||
export function clearAuthClientId() {
|
||||
uni.removeStorageSync(AUTH_CLIENT_ID_KEY)
|
||||
}
|
||||
|
||||
export function saveAuthSession(token: string, clientId?: string) {
|
||||
setAccessToken(token)
|
||||
setAuthClientId(clientId || AUTH_CLIENT_ID)
|
||||
}
|
||||
|
||||
export function setLoginPhoneNumber(phoneNumber: string) {
|
||||
uni.setStorageSync(LOGIN_PHONE_KEY, phoneNumber)
|
||||
}
|
||||
@@ -27,5 +49,6 @@ export function clearLoginPhoneNumber() {
|
||||
|
||||
export function clearAuthSession() {
|
||||
clearAccessToken()
|
||||
clearAuthClientId()
|
||||
clearLoginPhoneNumber()
|
||||
}
|
||||
|
||||
94
src/utils/build-case-library-query.ts
Normal file
94
src/utils/build-case-library-query.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CASE_PAGE_SIZE } from '@/constants/case-library'
|
||||
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
|
||||
import type { CaseLibraryListQuery } from '@/types/case-library-api'
|
||||
|
||||
const TIME_RANGE_API_MAP: Record<string, string> = {
|
||||
近3个月: '3m',
|
||||
近半年: '6m',
|
||||
近一年: '1y',
|
||||
}
|
||||
|
||||
function scaleRangeToBounds(range: string): { min?: number, max?: number } {
|
||||
switch (range) {
|
||||
case '50万以下':
|
||||
return { max: 50 }
|
||||
case '50-100万':
|
||||
return { min: 50, max: 100 }
|
||||
case '100-300万':
|
||||
return { min: 100, max: 300 }
|
||||
case '300-500万':
|
||||
return { min: 300, max: 500 }
|
||||
case '500万以上':
|
||||
return { min: 500 }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeScaleBounds(scales: string[]) {
|
||||
let scaleMin: number | undefined
|
||||
let scaleMax: number | undefined
|
||||
|
||||
for (const range of scales) {
|
||||
const bounds = scaleRangeToBounds(range)
|
||||
if (bounds.min !== undefined)
|
||||
scaleMin = scaleMin === undefined ? bounds.min : Math.min(scaleMin, bounds.min)
|
||||
if (bounds.max !== undefined)
|
||||
scaleMax = scaleMax === undefined ? bounds.max : Math.max(scaleMax, bounds.max)
|
||||
}
|
||||
|
||||
return { scaleMin, scaleMax }
|
||||
}
|
||||
|
||||
export interface BuildCaseLibraryQueryInput {
|
||||
keyword: string
|
||||
activeCategory: CaseCategory
|
||||
filter: CaseFilterValues
|
||||
pageNum: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function buildCaseLibraryQuery(input: BuildCaseLibraryQueryInput): CaseLibraryListQuery {
|
||||
const {
|
||||
keyword,
|
||||
activeCategory,
|
||||
filter,
|
||||
pageNum,
|
||||
pageSize = CASE_PAGE_SIZE,
|
||||
} = input
|
||||
|
||||
const query: CaseLibraryListQuery = {
|
||||
pageNum,
|
||||
pageSize,
|
||||
}
|
||||
|
||||
const text = keyword.trim()
|
||||
if (text)
|
||||
query.keyword = text
|
||||
|
||||
if (activeCategory !== '全部')
|
||||
query.businessType = activeCategory
|
||||
|
||||
if (filter.industries.length === 1)
|
||||
query.industry = filter.industries[0]
|
||||
else if (filter.industries.length > 1)
|
||||
query.industry = filter.industries.join(',')
|
||||
|
||||
const { scaleMin, scaleMax } = mergeScaleBounds(filter.scales)
|
||||
if (scaleMin !== undefined)
|
||||
query.scaleMin = scaleMin
|
||||
if (scaleMax !== undefined)
|
||||
query.scaleMax = scaleMax
|
||||
|
||||
if (filter.dateRange.start && filter.dateRange.end) {
|
||||
query.completedAtBegin = filter.dateRange.start.replace(/\//g, '-')
|
||||
query.completedAtEnd = filter.dateRange.end.replace(/\//g, '-')
|
||||
}
|
||||
else if (filter.timeRanges.length === 1) {
|
||||
const mapped = TIME_RANGE_API_MAP[filter.timeRanges[0]]
|
||||
if (mapped)
|
||||
query.timeRange = mapped
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
104
src/utils/map-case-library.ts
Normal file
104
src/utils/map-case-library.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { CASE_CATEGORIES } from '@/constants/case-library'
|
||||
import type { CaseCategory, CaseDetail, CaseItem } from '@/types/case-library'
|
||||
import type { ProjectCaseVo } from '@/types/case-library-api'
|
||||
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
|
||||
|
||||
const FALLBACK_COVER = '/static/icons/background.png'
|
||||
|
||||
function mapBusinessType(value?: string): Exclude<CaseCategory, '全部'> {
|
||||
const categories = CASE_CATEGORIES.filter(item => item !== '全部') as Exclude<CaseCategory, '全部'>[]
|
||||
if (value && categories.includes(value as Exclude<CaseCategory, '全部'>))
|
||||
return value as Exclude<CaseCategory, '全部'>
|
||||
return '互联网软件'
|
||||
}
|
||||
|
||||
function formatScale(scale?: number | string | null) {
|
||||
if (scale === null || scale === undefined || scale === '')
|
||||
return '—'
|
||||
const num = Number(scale)
|
||||
if (Number.isNaN(num))
|
||||
return String(scale)
|
||||
return `${num}万`
|
||||
}
|
||||
|
||||
function formatCycle(cycle?: string | number | null) {
|
||||
if (cycle === null || cycle === undefined || cycle === '')
|
||||
return '—'
|
||||
return `${cycle}个工作日`
|
||||
}
|
||||
|
||||
function formatCompletedAt(value?: string | null) {
|
||||
if (!value)
|
||||
return '—'
|
||||
return value.replace(/-/g, '/').slice(0, 10)
|
||||
}
|
||||
|
||||
function formatCustomerType(value?: string | null) {
|
||||
if (!value)
|
||||
return '—'
|
||||
return value
|
||||
}
|
||||
|
||||
function attachmentIcon(name: string) {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.webp'))
|
||||
return '/static/icons/PNG.png'
|
||||
return '/static/icons/PDF.png'
|
||||
}
|
||||
|
||||
export async function mapProjectCaseToCaseDetail(vo: ProjectCaseVo): Promise<CaseDetail> {
|
||||
const [baseItem] = await mapProjectCasesToCaseItems([vo])
|
||||
const attachmentSources = (vo.attachments ?? []).flatMap(item => [item.url, item.ossId])
|
||||
const urlMap = await resolveOssUrlMap(attachmentSources)
|
||||
|
||||
const catalogs = [...(vo.catalogs ?? [])]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map(item => ({
|
||||
id: String(item.id ?? item.title ?? ''),
|
||||
title: item.title || '—',
|
||||
content: item.content || '',
|
||||
}))
|
||||
|
||||
const attachments = [...(vo.attachments ?? [])]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((item) => {
|
||||
const name = item.originalName || '附件'
|
||||
const rawUrl = item.url || item.ossId
|
||||
return {
|
||||
id: String(item.id ?? name),
|
||||
name,
|
||||
icon: attachmentIcon(name),
|
||||
url: pickMediaUrl(rawUrl, urlMap) || rawUrl || undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...baseItem,
|
||||
catalogs,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapProjectCasesToCaseItems(rows: ProjectCaseVo[]): Promise<CaseItem[]> {
|
||||
const coverIds = rows.map(row => row.coverUrl).filter(Boolean) as string[]
|
||||
const urlMap = await resolveOssUrlMap(coverIds)
|
||||
|
||||
return rows.map((row) => {
|
||||
const industry = row.industry || row.businessIndustry || '—'
|
||||
const cover = pickMediaUrl(row.coverUrl || undefined, urlMap) || FALLBACK_COVER
|
||||
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: row.caseName || '未命名案例',
|
||||
category: mapBusinessType(row.businessType),
|
||||
cover,
|
||||
clientType: formatCustomerType(row.customerType),
|
||||
industry,
|
||||
scale: formatScale(row.scale),
|
||||
cycle: formatCycle(row.projectCycle),
|
||||
teamSize: row.serviceTeam != null ? `${row.serviceTeam}人` : '—',
|
||||
completedAt: formatCompletedAt(row.completedAt),
|
||||
favorite: Boolean(row.favorited),
|
||||
}
|
||||
})
|
||||
}
|
||||
32
src/utils/map-project-submission.ts
Normal file
32
src/utils/map-project-submission.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ProjectSubmissionItem } from '@/types/project-submission'
|
||||
import type { ServiceItem } from '@/types/home'
|
||||
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
|
||||
|
||||
const SERVICE_THEMES: ServiceItem['theme'][] = ['blue', 'green', 'pink', 'orange']
|
||||
|
||||
function collectMediaIds(items: ProjectSubmissionItem[]) {
|
||||
const ids: string[] = []
|
||||
for (const item of items) {
|
||||
if (item.logoUrl)
|
||||
ids.push(String(item.logoUrl))
|
||||
if (item.coverUrl)
|
||||
ids.push(String(item.coverUrl))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export async function mapProjectSubmissionsToServices(
|
||||
list: ProjectSubmissionItem[],
|
||||
): Promise<ServiceItem[]> {
|
||||
const enabled = list.filter(item => item.enabled === 1 || item.enabled === undefined)
|
||||
const urlMap = await resolveOssUrlMap(collectMediaIds(enabled))
|
||||
|
||||
return enabled.map((item, index) => ({
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
desc: item.subtitle || item.businessType || '',
|
||||
theme: SERVICE_THEMES[index % SERVICE_THEMES.length],
|
||||
illus: pickMediaUrl(item.logoUrl, urlMap) || pickMediaUrl(item.coverUrl, urlMap),
|
||||
submissionId: String(item.id),
|
||||
}))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { API_BASE_URL, API_TIMEOUT } from '@/constants/config'
|
||||
import { getAccessToken } from '@/utils/auth-storage'
|
||||
import { handleUnauthorized } from '@/utils/auth-guard'
|
||||
import { getAccessToken, getAuthClientId } from '@/utils/auth-storage'
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string
|
||||
@@ -41,12 +42,14 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getAccessToken()
|
||||
const clientId = getAuthClientId()
|
||||
uni.request({
|
||||
url: resolveUrl(url),
|
||||
method,
|
||||
data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(clientId ? { clientid: clientId } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...header,
|
||||
},
|
||||
@@ -64,6 +67,12 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
}
|
||||
|
||||
const message = getErrorMessage(apiBody, '请求失败')
|
||||
if (apiBody.code === 401) {
|
||||
handleUnauthorized(message)
|
||||
reject(new Error(message))
|
||||
return
|
||||
}
|
||||
|
||||
if (showErrorToast)
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
reject(new Error(message))
|
||||
@@ -74,6 +83,12 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
return
|
||||
}
|
||||
|
||||
if (statusCode === 401) {
|
||||
handleUnauthorized('登录状态异常,请重新登录')
|
||||
reject(new Error('登录状态异常,请重新登录'))
|
||||
return
|
||||
}
|
||||
|
||||
const message = `请求失败(${statusCode})`
|
||||
if (showErrorToast)
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
|
||||
36
src/utils/resolve-oss-media.ts
Normal file
36
src/utils/resolve-oss-media.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getOssListByIds } from '@/api/oss'
|
||||
|
||||
export function isHttpUrl(value?: string) {
|
||||
return Boolean(value && /^https?:\/\//.test(value))
|
||||
}
|
||||
|
||||
export async function resolveOssUrlMap(rawIds: Array<string | undefined>) {
|
||||
const map = new Map<string, string>()
|
||||
const ossIds = rawIds
|
||||
.filter((id): id is string => Boolean(id && !isHttpUrl(id)))
|
||||
.map(String)
|
||||
|
||||
if (!ossIds.length)
|
||||
return map
|
||||
|
||||
try {
|
||||
const files = await getOssListByIds(ossIds)
|
||||
for (const file of files ?? []) {
|
||||
if (file.ossId && file.url)
|
||||
map.set(String(file.ossId), file.url)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export function pickMediaUrl(raw?: string, urlMap?: Map<string, string>) {
|
||||
if (!raw)
|
||||
return undefined
|
||||
if (isHttpUrl(raw))
|
||||
return raw
|
||||
return urlMap?.get(String(raw))
|
||||
}
|
||||
22
src/utils/resolve-project-submission-media.ts
Normal file
22
src/utils/resolve-project-submission-media.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ProjectSubmissionItem } from '@/types/project-submission'
|
||||
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
|
||||
|
||||
export async function resolveProjectSubmissionMedia(item: ProjectSubmissionItem) {
|
||||
const serviceIconIds = (item.serviceTypes ?? []).map(st => st.iconUrl)
|
||||
const urlMap = await resolveOssUrlMap([item.logoUrl, item.coverUrl, ...serviceIconIds])
|
||||
|
||||
const serviceIcons: Record<string, string> = {}
|
||||
for (const st of item.serviceTypes ?? []) {
|
||||
if (!st.id)
|
||||
continue
|
||||
const url = pickMediaUrl(st.iconUrl, urlMap)
|
||||
if (url)
|
||||
serviceIcons[String(st.id)] = url
|
||||
}
|
||||
|
||||
return {
|
||||
logoUrl: pickMediaUrl(item.logoUrl, urlMap) || pickMediaUrl(item.coverUrl, urlMap),
|
||||
coverUrl: pickMediaUrl(item.coverUrl, urlMap),
|
||||
serviceIcons,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user