This commit is contained in:
wangzzpeng
2026-09-24 10:10:43 +08:00
commit 7bc84bbe5e
194 changed files with 22571 additions and 0 deletions

35
src/views/404.vue Normal file
View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
function goHome(): void {
window.location.href = '/'
}
</script>
<template>
<section class="not-found">
<h1>404</h1>
<p class="text-secondary">
页面不存在或已下线
</p>
<button class="not-found__back link" @click="goHome">
返回首页
</button>
</section>
</template>
<style lang="less" scoped>
.not-found {
.flex(column, center);
gap: @spacing-base;
min-height: 100vh;
h1 {
font-size: 64px;
color: @heading-color;
}
&__back {
color: @primary-color;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { formatBusinessType } from '@/utils/format'
import type { Case } from '@/types/business/cases'
interface Props {
item: Case
}
defineProps<Props>()
const emit = defineEmits<{
favorite: [id: string]
share: [id: string]
}>()
</script>
<template>
<header class="case-header">
<div class="case-header__main">
<div class="case-header__head">
<h1 class="case-header__title">
{{ item.title }}
</h1>
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
</div>
<div class="case-header__actions">
<button
class="case-header__btn"
:class="{ 'is-favorite': item.isFavorite }"
@click="emit('favorite', item.id)"
>
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
</button>
<button class="case-header__btn" @click="emit('share', item.id)">
分享
</button>
</div>
<div class="case-header__meta">
<div class="case-header__meta-item">
<span class="text-secondary">完成时间</span>
<span>{{ item.finishDate }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">服务团队</span>
<span>{{ item.teamSize }}人</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">规模</span>
<span>{{ item.scale }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">所属行业</span>
<span>{{ item.industry }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">项目周期</span>
<span>{{ item.duration }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">客户类型</span>
<span>{{ item.clientType }}</span>
</div>
</div>
</div>
<div class="case-header__video">
<!-- TODO(蓝湖): 接入真实视频源 -->
<span>视频将在预览时播放</span>
<span class="case-header__video-time">0:00 / 0:00</span>
</div>
</header>
</template>
<style lang="less" scoped>
.case-header {
.flex(row, flex-start);
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__main {
flex: 1;
}
&__head {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__tag {
padding: 2px @spacing-sm;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__btn {
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-favorite {
border-color: @warning-color;
color: @warning-color;
}
}
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
}
&__meta-item {
.flex(column, flex-start);
gap: 4px;
font-size: @font-size-base;
color: @heading-color;
}
&__video {
.flex(column, center);
gap: @spacing-sm;
width: 320px;
height: 200px;
border-radius: @radius-base;
background: @heading-color;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__video-time {
color: @text-color-disabled;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { Case } from '@/types/business/cases'
interface Props {
item: Case
}
defineProps<Props>()
const sections = [
{ key: 'background', title: '项目背景', field: 'background' },
{ key: 'painPoints', title: '客户痛点', field: 'painPoints' },
{ key: 'requirements', title: '需求描述', field: 'requirements' },
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
] as const
</script>
<template>
<div class="case-rich">
<section v-for="section in sections" :key="section.key" class="case-rich__section">
<h3 class="case-rich__title">
{{ section.title }}
</h3>
<p class="case-rich__text">{{ item[section.field] }}</p>
</section>
</div>
</template>
<style lang="less" scoped>
.case-rich {
.flex(column, flex-start);
gap: @spacing-base;
&__section {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
padding-left: @spacing-sm;
border-left: 3px solid @primary-color;
}
&__text {
font-size: @font-size-base;
line-height: 1.8;
color: @text-color;
white-space: pre-wrap;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElDatePicker } from 'element-plus'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
interface Props {
query: CaseQuery
counts: Partial<Record<BusinessType, number>>
total: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: CaseQuery]
search: []
reset: []
}>()
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
const activeShortcut = ref<TimeShortcut>('all')
watch(
() => props.query.dateRange,
(value) => {
if (!value) activeShortcut.value = 'all'
},
)
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
const industryOptions = [
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
]
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
{ label: '50万以下', value: 'lt50w' },
{ label: '50-100万', value: '50to100w' },
{ label: '100-300万', value: '100to300w' },
{ label: '300-500万', value: '300to500w' },
{ label: '500万以上', value: 'gt500w' },
]
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.category !== 'all') {
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
}
for (const industry of props.query.industries) {
list.push({ key: `industry:${industry}`, label: industry })
}
for (const scale of props.query.scales) {
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
}
if (props.query.dateRange) {
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
list.push({
key: 'time',
label: shortcutLabel && shortcutLabel !== '全部'
? shortcutLabel
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
})
}
return list
})
function patch(partial: Partial<CaseQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function setCategory(value: BusinessType | 'all'): void {
patch({ category: value })
}
function toggleIndustry(industry: string): void {
const next = props.query.industries.includes(industry)
? props.query.industries.filter((item) => item !== industry)
: [...props.query.industries, industry]
patch({ industries: next })
}
function toggleScale(scale: ScaleRange): void {
const next = props.query.scales.includes(scale)
? props.query.scales.filter((item) => item !== scale)
: [...props.query.scales, scale]
patch({ scales: next })
}
function setShortcut(value: TimeShortcut): void {
activeShortcut.value = value
if (value === 'all') {
patch({ dateRange: null })
return
}
const end = new Date()
const start = new Date()
if (value === '3m') start.setMonth(start.getMonth() - 3)
if (value === '6m') start.setMonth(start.getMonth() - 6)
if (value === '1y') start.setFullYear(start.getFullYear() - 1)
patch({
dateRange: [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')],
})
}
function onDateRange(value: [Date, Date] | null): void {
activeShortcut.value = 'custom'
patch({
dateRange: value
? [formatDate(value[0], 'YYYY-MM-DD'), formatDate(value[1], 'YYYY-MM-DD')]
: null,
})
}
function onRemoveChip(key: string): void {
if (key === 'category') {
setCategory('all')
}
else if (key === 'time') {
activeShortcut.value = 'all'
patch({ dateRange: null })
}
else if (key.startsWith('industry:')) {
const industry = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== industry) })
}
else if (key.startsWith('scale:')) {
const scale = key.slice('scale:'.length) as ScaleRange
patch({ scales: props.query.scales.filter((item) => item !== scale) })
}
}
function onClearChips(): void {
activeShortcut.value = 'all'
emit('update:query', {
...props.query,
category: 'all',
industries: [],
scales: [],
dateRange: null,
})
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选 + 计数) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">业务类型</span>
<div class="cases-filter__options">
<button
v-for="option in categoryOptions"
:key="option.value"
class="cases-filter__option"
:class="{ 'is-active': query.category === option.value }"
@click="setCategory(option.value)"
>
{{ option.label }}
<span class="cases-filter__count">
{{ option.value === 'all' ? total : (counts[option.value] ?? 0) }}
</span>
</button>
</div>
</div>
<!-- 所属行业(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">所属行业</span>
<div class="cases-filter__options">
<button
class="cases-filter__option"
:class="{ 'is-active': query.industries.length === 0 }"
@click="patch({ industries: [] })"
>
全部
</button>
<button
v-for="industry in industryOptions"
:key="industry"
class="cases-filter__option"
:class="{ 'is-active': query.industries.includes(industry) }"
@click="toggleIndustry(industry)"
>
{{ industry }}
</button>
</div>
</div>
<!-- 项目规模(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">项目规模</span>
<div class="cases-filter__options">
<button
class="cases-filter__option"
:class="{ 'is-active': query.scales.length === 0 }"
@click="patch({ scales: [] })"
>
全部
</button>
<button
v-for="scale in scaleOptions"
:key="scale.value"
class="cases-filter__option"
:class="{ 'is-active': query.scales.includes(scale.value) }"
@click="toggleScale(scale.value)"
>
{{ scale.label }}
</button>
</div>
</div>
<!-- 完成时间(快捷区间 + 自定义) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">完成时间</span>
<div class="cases-filter__options">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="cases-filter__option"
:class="{ 'is-active': activeShortcut === shortcut.value }"
@click="setShortcut(shortcut.value)"
>
{{ shortcut.label }}
</button>
<ElDatePicker
v-model="query.dateRange"
type="daterange"
range-separator="—"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
class="cases-filter__date"
@change="onDateRange"
/> </div>
</div>
</FilterPanel>
</template>
<style lang="less" scoped>
.cases-filter {
&__group {
.flex(row, flex-start);
gap: @spacing-base;
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
&__count {
margin-left: 4px;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__date {
width: 240px;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CaseCard } from '@/components'
import type { Case } from '@/types/business/cases'
interface Props {
cases: Case[]
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
</script>
<template>
<div class="cases-grid">
<template v-if="cases.length > 0">
<CaseCard
v-for="item in cases"
:key="item.id"
:item="item"
@view="emit('view', $event)"
/>
</template>
<p v-else class="cases-grid__empty text-secondary">
暂无符合条件的案例,请调整筛选条件
</p>
</div>
</template>
<style lang="less" scoped>
.cases-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useCasesStore } from '@/stores/modules/cases'
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
import CaseRichText from './cpns/CaseRichText.vue'
const route = useRoute()
const router = useRouter()
const casesStore = useCasesStore()
const item = computed(() => casesStore.currentCase)
const caseId = computed(() => String(route.params.id))
function load(): void {
void casesStore.fetchCaseDetail(caseId.value)
}
async function onFavorite(id: string): Promise<void> {
await casesStore.toggleFavorite(id)
ElMessage.success(item.value?.isFavorite ? '已加入收藏' : '已取消收藏')
}
async function onShare(): Promise<void> {
try {
await navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制,快去分享吧')
}
catch {
ElMessage.error('复制失败,请手动复制地址栏链接')
}
}
watch(caseId, load)
onMounted(load)
</script>
<template>
<div class="case-detail">
<nav class="case-detail__breadcrumb text-secondary">
<RouterLink to="/cases" class="link">
案例库
</RouterLink>
<span> &gt; </span>
<span>案例详情</span>
</nav>
<template v-if="item">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<div class="case-detail__cta">
<AppButton type="primary" size="lg" @click="router.push('/demand')">
一键填报类似案例
</AppButton>
</div>
</template>
<p v-else class="case-detail__empty text-secondary">
案例不存在或已下线
</p>
</div>
</template>
<style lang="less" scoped>
.case-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__cta {
.flex(row, center);
padding-top: @spacing-base;
}
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

5
src/views/cases/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { default } from './index.vue'
export { default as CasesFilter } from './cpns/CasesFilter.vue'
export { default as CasesGrid } from './cpns/CasesGrid.vue'
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
export { default as CaseRichText } from './cpns/CaseRichText.vue'

90
src/views/cases/index.vue Normal file
View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useCasesStore } from '@/stores/modules/cases'
import type { CaseQuery } from '@/types/business/cases'
import CasesFilter from './cpns/CasesFilter.vue'
import CasesGrid from './cpns/CasesGrid.vue'
const router = useRouter()
const casesStore = useCasesStore()
const { query, list, total, counts } = storeToRefs(casesStore)
function onUpdateQuery(next: CaseQuery): void {
casesStore.setQuery(next)
}
function onSearch(): void {
void casesStore.search()
}
function onReset(): void {
void casesStore.resetQuery()
}
function onPageChange(page: number): void {
void casesStore.changePage(page)
}
function onViewDetail(id: string): void {
void router.push(`/cases/${id}`)
}
onMounted(() => {
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
})
</script>
<template>
<div class="cases">
<header class="cases__head">
<h1 class="cases__title">
成功案例库
</h1>
<p class="cases__slogan text-secondary">
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
</p>
</header>
<CasesFilter
:query="query"
:counts="counts"
:total="total"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<CasesGrid :cases="list" @view="onViewDetail" />
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.cases {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FileInfo } from '@/types/api'
import type { Contract, SignStatus } from '@/types/business/contract'
import { formatFileSize } from '@/utils/format'
interface Props {
contract: Contract
}
defineProps<Props>()
const emit = defineEmits<{
viewProject: []
preview: [file: FileInfo]
download: [file: FileInfo]
}>()
const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'履行中': 'success',
'已过期': 'danger',
'待签署': 'warning',
}
</script>
<template>
<div class="contract-info">
<!-- 只读信息网格 -->
<div class="contract-info__grid">
<div class="contract-info__field">
<span class="contract-info__label text-secondary">合同名称</span>
<span class="contract-info__value">{{ contract.contractName }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">合同类型</span>
<span class="contract-info__value">{{ contract.contractType }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">合同编号</span>
<span class="contract-info__value">{{ contract.contractNo }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">签订日期</span>
<span class="contract-info__value">{{ contract.signDate || '—' }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">生效日期</span>
<span class="contract-info__value">{{ contract.effectiveDate }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">到期日期</span>
<span class="contract-info__value">{{ contract.expiryDate }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">合同状态</span>
<StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" />
</div>
</div>
<!-- 关联项目 -->
<div class="contract-info__related">
<h3 class="contract-info__section-title">
关联项目
</h3>
<div class="contract-info__related-body">
<div class="contract-info__field">
<span class="contract-info__label text-secondary">项目编号</span>
<span class="contract-info__value">{{ contract.projectNo }}</span>
</div>
<div class="contract-info__field">
<span class="contract-info__label text-secondary">项目名称</span>
<span class="contract-info__value">{{ contract.relatedProjectName }}</span>
</div>
<button class="contract-info__link" @click="emit('viewProject')">
查看详情
</button>
</div>
</div>
<!-- 合同文件 -->
<div class="contract-info__files-section">
<h3 class="contract-info__section-title">
合同文件
</h3>
<ul class="contract-info__files">
<li
v-for="(file, index) in contract.files"
:key="`${file.name}-${index}`"
class="contract-info__file"
>
<div class="contract-info__file-info">
<span class="contract-info__file-name" :title="file.name">{{ file.name }}</span>
<span class="contract-info__file-meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
</span>
</div>
<div class="contract-info__file-actions">
<button class="contract-info__link" @click="emit('preview', file)">
预览
</button>
<button class="contract-info__link" @click="emit('download', file)">
下载
</button>
</div>
</li>
<li v-if="contract.files.length === 0" class="contract-info__empty text-secondary">
暂无合同文件
</li>
</ul>
</div>
</div>
</template>
<style lang="less" scoped>
.contract-info {
.flex(column, flex-start);
gap: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @spacing-base @spacing-xl;
}
&__field {
.flex(row, flex-start, center);
gap: @spacing-sm;
min-width: 0;
}
&__label {
flex-shrink: 0;
width: 70px;
font-size: @font-size-sm;
}
&__value {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__section-title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__related,
&__files-section {
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
&__related-body {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-base;
}
&__link {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
}
&__files {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__file {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__file-info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__file-name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__file-meta {
font-size: @font-size-sm;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { QrCodePanel } from '@/components'
import SignSteps from './SignSteps.vue'
interface Props {
expiresIn?: number
}
withDefaults(defineProps<Props>(), { expiresIn: 272 })
const emit = defineEmits<{ refresh: [] }>()
</script>
<template>
<div class="sign-panel">
<div class="sign-panel__left">
<QrCodePanel :expires-in="expiresIn" @refresh="emit('refresh')" />
</div>
<div class="sign-panel__right">
<SignSteps />
</div>
</div>
</template>
<style lang="less" scoped>
.sign-panel {
.flex(row, flex-start, stretch);
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__left {
.flex(row, center);
flex-shrink: 0;
padding: @spacing-lg;
border-radius: @radius-base;
background: @bg-color-light;
}
&__right {
flex: 1;
min-width: 0;
}
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { Contract, SignStatus } from '@/types/business/contract'
import { formatMoney } from '@/utils/format'
interface Props {
contracts: Contract[]
}
defineProps<Props>()
const emit = defineEmits<{
sign: [id: string]
view: [id: string]
}>()
const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'履行中': 'success',
'已过期': 'danger',
'待签署': 'warning',
}
</script>
<template>
<div class="contract-table">
<table class="contract-table__grid">
<thead>
<tr>
<th>合同编号</th>
<th>合同名称</th>
<th>合同类型</th>
<th>合同金额</th>
<th>关联项目</th>
<th>项目编号</th>
<th>签署状态</th>
<th>生效时间</th>
<th>到期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in contracts" :key="item.id">
<td>{{ item.contractNo }}</td>
<td class="contract-table__name" :title="item.contractName">
{{ item.contractName }}
</td>
<td>{{ item.contractType }}</td>
<td>¥{{ formatMoney(item.contractAmount) }}</td>
<td class="contract-table__name" :title="item.relatedProjectName">
{{ item.relatedProjectName }}
</td>
<td>{{ item.projectNo }}</td>
<td>
<StatusTag :label="item.signStatus" :variant="SIGN_VARIANTS[item.signStatus]" />
</td>
<td>{{ item.effectiveDate }}</td>
<td>{{ item.expiryDate }}</td>
<td class="contract-table__actions">
<button
v-if="item.signStatus === '待签署'"
class="contract-table__btn contract-table__btn--primary"
@click="emit('sign', item.id)"
>
去签署
</button>
<button class="contract-table__btn" @click="emit('view', item.id)">
详情
</button>
</td>
</tr>
<tr v-if="contracts.length === 0">
<td colspan="10" class="contract-table__empty text-secondary">
暂无符合条件的合同
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="less" scoped>
.contract-table {
overflow-x: auto;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.scrollbar();
&__grid {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
th,
td {
padding: @spacing-base;
text-align: left;
border-bottom: 1px solid @border-color;
white-space: nowrap;
}
th {
background: @bg-color-light;
color: @heading-color;
font-weight: 600;
}
td {
color: @text-color;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: @bg-color-light;
}
}
&__name {
max-width: 200px;
color: @heading-color;
.ellipsis();
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
}
&__btn {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--primary {
height: 28px;
padding: 0 @spacing-sm;
border: none;
border-radius: @radius-sm;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
opacity: 1;
}
}
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
const steps = [
{
title: '打开手机支付宝',
description: '点击右上角 + 选择扫一扫,扫描本页二维码',
},
{
title: '验证身份信息',
description: '跟随提示进行实名验证,确保本人操作',
},
{
title: '确认合同内容并签署',
description: '确定合同内容无误根据提示在指定位置进行合同签署',
},
{
title: '完成签署',
description: '签署完成请耐心等待系统反应,本页面将自动跳转至合同详情页',
},
]
</script>
<template>
<ol class="sign-steps">
<li
v-for="(step, index) in steps"
:key="step.title"
class="sign-steps__item"
>
<span class="sign-steps__index">{{ index + 1 }}</span>
<div class="sign-steps__body">
<h3 class="sign-steps__title">{{ step.title }}</h3>
<p class="sign-steps__desc text-secondary">{{ step.description }}</p>
</div>
</li>
</ol>
</template>
<style lang="less" scoped>
.sign-steps {
.flex(column, flex-start);
gap: @spacing-lg;
&__item {
.flex(row, flex-start);
gap: @spacing-base;
}
&__index {
.flex(row, center);
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
}
&__body {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-base;
line-height: 1.6;
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton } from '@/components'
import type { FileInfo } from '@/types/api'
// 页面容器:数据只经 store,不直接 import api
import { useContractStore } from '@/stores/modules/contract'
import ContractInfoCard from './cpns/ContractInfoCard.vue'
const route = useRoute()
const router = useRouter()
const contractStore = useContractStore()
const { current } = storeToRefs(contractStore)
const contractId = computed(() => String(route.params.id))
function load(): void {
void contractStore.fetchDetail(contractId.value)
}
function onBack(): void {
void router.push('/contract')
}
function onViewProject(): void {
// TODO: 关联项目 id 由接口返回后跳转项目详情
ElMessage.info('项目详情跳转待接口接入')
}
function onPreview(file: FileInfo): void {
// TODO(api): 合同文件预览接口
ElMessage.info(`「${file.name}」预览待接口接入`)
}
function onDownload(file: FileInfo): void {
// TODO(api): 合同文件下载接口
ElMessage.info(`「${file.name}」下载待接口接入`)
}
watch(contractId, load)
onMounted(load)
</script>
<template>
<div class="contract-detail">
<nav class="contract-detail__breadcrumb text-secondary">
<RouterLink to="/contract" class="link">
合同中心
</RouterLink>
<span> &gt; </span>
<span>合同详情</span>
</nav>
<ContractInfoCard
v-if="current"
:contract="current"
@view-project="onViewProject"
@preview="onPreview"
@download="onDownload"
/>
<p v-else class="contract-detail__empty text-secondary">
合同不存在或已下线
</p>
<div class="contract-detail__footer">
<AppButton @click="onBack">
返回
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.contract-detail {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__footer {
.flex(row, flex-start);
}
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default } from './index.vue'
export { default as ContractTable } from './cpns/ContractTable.vue'
export { default as ContractInfoCard } from './cpns/ContractInfoCard.vue'
export { default as ContractSignPanel } from './cpns/ContractSignPanel.vue'
export { default as SignSteps } from './cpns/SignSteps.vue'

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { FilterPanel, PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useContractStore } from '@/stores/modules/contract'
import ContractTable from './cpns/ContractTable.vue'
const router = useRouter()
const contractStore = useContractStore()
const { query, list, total } = storeToRefs(contractStore)
const keyword = computed({
get: () => query.value.keyword,
set: (value: string) => contractStore.setQuery({ ...query.value, keyword: value }),
})
function onSearch(): void {
void contractStore.search()
}
function onReset(): void {
void contractStore.resetQuery()
}
function onPageChange(page: number): void {
void contractStore.changePage(page)
}
function onView(id: string): void {
void router.push(`/contract/${id}`)
}
function onSign(id: string): void {
void router.push(`/contract/${id}/sign`)
}
onMounted(() => {
void contractStore.fetchList()
})
</script>
<template>
<div class="contract">
<header class="contract__head">
<h1 class="contract__title">
合同中心
</h1>
<p class="contract__slogan text-secondary">
项目电子合同在线签署、归档与查询管理
</p>
</header>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="合同名称/合同编号搜索"
@search="onSearch"
@reset="onReset"
/>
<ContractTable
:contracts="list"
@view="onView"
@sign="onSign"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.contract {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useContractStore } from '@/stores/modules/contract'
import ContractSignPanel from './cpns/ContractSignPanel.vue'
const route = useRoute()
const router = useRouter()
const contractStore = useContractStore()
const { signExpires } = storeToRefs(contractStore)
const contractId = computed(() => String(route.params.id))
function load(): void {
void contractStore.fetchSignQr(contractId.value)
}
async function onRefresh(): Promise<void> {
await contractStore.fetchSignQr(contractId.value, true)
ElMessage.success('二维码已刷新')
}
function onBack(): void {
void router.push(`/contract/${contractId.value}`)
}
watch(contractId, load)
onMounted(load)
</script>
<template>
<div class="contract-sign">
<nav class="contract-sign__breadcrumb text-secondary">
<RouterLink to="/contract" class="link">
合同中心
</RouterLink>
<span> &gt; </span>
<RouterLink :to="`/contract/${contractId}`" class="link">
合同详情
</RouterLink>
<span> &gt; </span>
<span>签署合同</span>
</nav>
<ContractSignPanel
:expires-in="signExpires"
@refresh="onRefresh"
/>
<div class="contract-sign__footer">
<AppButton @click="onBack">
返回
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.contract-sign {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__footer {
.flex(row, flex-start);
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
defineProps<Props>()
const emit = defineEmits<{ select: [businessType: BusinessType] }>()
interface BusinessOption {
value: BusinessType
name: string
desc: string
}
const options: BusinessOption[] = [
{ value: 'internet', name: '互联网软件', desc: 'Web/小程序/APP/二次开发/桌面工具等' },
{ value: 'iot', name: '物联网全案', desc: '软硬件开发+云端一体化/定制化开发等' },
{ value: 'manufacturing', name: '生产制造', desc: 'PCB制版/SMT/整机装配/定制生产等' },
{ value: 'hardware', name: '硬件电路', desc: '原理图设计/PCB设计/电气效验等' },
{ value: 'other', name: '其他', desc: '若没有对应的服务内容请选择该选项' },
]
</script>
<template>
<div class="demand-step1">
<p class="demand-step1__title">
请选择业务类型
</p>
<div class="demand-step1__grid">
<button
v-for="option in options"
:key="option.value"
class="demand-step1__card"
:class="{ 'is-active': demand.businessType === option.value }"
@click="emit('select', option.value)"
>
<span class="demand-step1__name">{{ option.name }}</span>
<span class="demand-step1__desc">{{ option.desc }}</span>
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step1 {
.flex(column, flex-start);
gap: @spacing-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__grid {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__card {
.flex(column, flex-start);
gap: @spacing-xs;
width: calc((100% - 32px) / 3);
min-width: 220px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
box-shadow: @shadow-base;
}
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-sm;
color: @text-color-secondary;
line-height: 1.5;
}
&__card.is-active &__name {
color: @primary-color;
}
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { FileList, UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import type { BudgetRange, Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:demand': [partial: Partial<Demand>]
upload: [file: File]
'remove-file': [file: FileInfo]
}>()
const budgetOptions: Array<{ label: string; value: Exclude<BudgetRange, ''> }> = [
{ label: '1万以下', value: 'lt1w' },
{ label: '1-5万', value: '1to5w' },
{ label: '5-10万', value: '5to10w' },
{ label: '10万+', value: 'gt10w' },
]
const descriptionLength = computed(() => props.demand.coreDescription.length)
function onProjectName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { projectName: target.value })
}
function onCoreDescription(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { coreDescription: target.value })
}
function onDuration(event: Event): void {
const target = event.target as HTMLInputElement
const value = target.value === '' ? '' : Number(target.value)
emit('update:demand', { expectedDuration: value })
}
function onUpload(file: File): void {
emit('upload', file)
}
function onRemoveFile(file: FileInfo): void {
emit('remove-file', file)
}
function onFileAction(action: 'preview' | 'download', file: FileInfo): void {
ElMessage.info(`「${file.name}」${action === 'preview' ? '预览' : '下载'}功能待接口接入`)
}
</script>
<template>
<div class="demand-step2">
<div class="demand-step2__field">
<label class="demand-step2__label">项目名称</label>
<input
class="demand-step2__input"
type="text"
:value="demand.projectName"
placeholder="请输入项目名称"
@input="onProjectName"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">相关附件</label>
<UploadArea @upload="onUpload" />
<FileList
:files="demand.attachments"
@preview="(file) => onFileAction('preview', file)"
@download="(file) => onFileAction('download', file)"
@delete="onRemoveFile"
/>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">核心需求描述</label>
<div class="demand-step2__textarea-wrap">
<textarea
class="demand-step2__textarea"
:value="demand.coreDescription"
maxlength="500"
placeholder="请描述您的核心需求(业务目标、功能范围、交付期望等)"
@input="onCoreDescription"
/>
<span class="demand-step2__counter">{{ descriptionLength }}/500</span>
</div>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预计开发周期</label>
<input
class="demand-step2__input"
type="number"
:value="demand.expectedDuration"
min="1"
placeholder="请输入预计开发周期(工作日)"
@input="onDuration"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预算范围</label>
<div class="demand-step2__budgets">
<button
v-for="option in budgetOptions"
:key="option.value"
class="demand-step2__budget"
:class="{ 'is-active': demand.budgetRange === option.value }"
@click="emit('update:demand', { budgetRange: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step2 {
.flex(column, flex-start);
gap: @spacing-lg;
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__budgets {
.flex(row, flex-start);
gap: @spacing-sm;
flex-wrap: wrap;
}
&__budget {
height: 36px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{ 'update:demand': [partial: Partial<Demand>] }>()
const remarkLength = computed(() => props.demand.remark.length)
function onUserName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userName: target.value })
}
function onUserPhone(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userPhone: target.value })
}
function onRemark(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { remark: target.value })
}
</script>
<template>
<div class="demand-step3">
<div class="demand-step3__row">
<div class="demand-step3__field">
<label class="demand-step3__label">用户名称</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userName"
placeholder="请输入用户名称"
@input="onUserName"
>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">用户手机号</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userPhone"
placeholder="请输入用户手机号"
@input="onUserPhone"
>
</div>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">补充备注</label>
<div class="demand-step3__textarea-wrap">
<textarea
class="demand-step3__textarea"
:value="demand.remark"
maxlength="500"
placeholder="可补充其他说明(选填)"
@input="onRemark"
/>
<span class="demand-step3__counter">{{ remarkLength }}/500</span>
</div>
</div>
<p class="demand-step3__tip">
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
</div>
</template>
<style lang="less" scoped>
.demand-step3 {
.flex(column, flex-start);
gap: @spacing-lg;
&__row {
.flex(row, flex-start);
gap: @spacing-base;
width: 100%;
.demand-step3__field {
flex: 1;
min-width: 0;
}
}
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__tip {
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @primary-light;
font-size: @font-size-sm;
color: @primary-color;
line-height: 1.6;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export { default } from './index.vue'
export { default as DemandStep1Business } from './cpns/DemandStep1Business.vue'
export { default as DemandStep2Info } from './cpns/DemandStep2Info.vue'
export { default as DemandStep3Confirm } from './cpns/DemandStep3Confirm.vue'

151
src/views/demand/index.vue Normal file
View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { AppButton, StepForm } from '@/components'
import { useDemandStore } from '@/stores/modules/demand'
import { useUserStore } from '@/stores/modules/user'
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
import DemandStep1Business from './cpns/DemandStep1Business.vue'
import DemandStep2Info from './cpns/DemandStep2Info.vue'
import DemandStep3Confirm from './cpns/DemandStep3Confirm.vue'
const STEP_TITLES = ['选择业务类型', '填写需求信息', '确认信息并补充备注']
const router = useRouter()
const demandStore = useDemandStore()
const userStore = useUserStore()
const { form, step, loading } = storeToRefs(demandStore)
function onSelectBusiness(type: BusinessType): void {
demandStore.patchForm({ businessType: type })
}
function onUpdateDemand(partial: Partial<Demand>): void {
demandStore.patchForm(partial)
}
function onUpload(file: File): void {
demandStore.addAttachment(file)
}
function onRemoveFile(file: Demand['attachments'][number]): void {
demandStore.removeAttachment(file)
}
function goPrev(): void {
if (step.value > 1) demandStore.setStep(step.value - 1)
}
function goNext(): void {
if (step.value < 3) demandStore.setStep(step.value + 1)
}
async function onSubmit(): Promise<void> {
const ok = await demandStore.submit()
if (ok && userStore.isLoggedIn) {
void router.push('/project')
}
}
function onSaveDraft(): void {
void demandStore.saveDraft()
}
function onReset(): void {
demandStore.reset()
}
onMounted(() => {
void demandStore.loadDraft()
})
</script>
<template>
<div class="demand">
<header class="demand__head">
<h1 class="demand__title">
需求提报
</h1>
<p class="demand__subtitle text-secondary">
填写您的需求信息,专业顾问将快速与您联系
</p>
</header>
<StepForm :titles="STEP_TITLES" :current="step - 1">
<DemandStep1Business
v-if="step === 1"
:demand="form"
@select="onSelectBusiness"
/>
<DemandStep2Info
v-else-if="step === 2"
:demand="form"
@update:demand="onUpdateDemand"
@upload="onUpload"
@remove-file="onRemoveFile"
/>
<DemandStep3Confirm
v-else
:demand="form"
@update:demand="onUpdateDemand"
/>
</StepForm>
<div class="demand__nav">
<AppButton :disabled="step === 1 || loading" @click="goPrev">
上一步
</AppButton>
<AppButton v-if="step < 3" type="primary" :disabled="loading" @click="goNext">
下一步
</AppButton>
</div>
<div class="demand__actions">
<AppButton type="primary" size="lg" :loading="loading" @click="onSubmit">
立即提报
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onSaveDraft">
保存草稿
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onReset">
重置
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.demand {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__head {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__subtitle {
font-size: @font-size-base;
}
&__nav {
.flex(row, space-between);
width: 100%;
}
&__actions {
.flex(row, center);
gap: @spacing-base;
}
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { AppButton, StatusTag } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { currentBill } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
}
async function load(): Promise<void> {
await financeStore.fetchDetail(billId.value)
if (currentBill.value?.status === '已支付') {
await financeStore.fetchResult(billId.value)
}
}
function onPay(): void {
void router.push(`/finance/bills/${billId.value}/pay`)
}
watch(billId, () => {
void load()
})
onMounted(() => {
void load()
})
</script>
<template>
<div class="bill-detail">
<nav class="bill-detail__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>账单详情</span>
</nav>
<template v-if="currentBill">
<section class="bill-detail__card card">
<div class="bill-detail__card-head">
<h3 class="bill-detail__card-title">
订单信息
</h3>
<StatusTag :label="currentBill.status" :variant="STATUS_VARIANTS[currentBill.status]" />
</div>
<div class="bill-detail__fields">
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">账单名称</span>
<span class="bill-detail__value">{{ currentBill.planName }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">账单号</span>
<span class="bill-detail__value">{{ currentBill.billNo }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">项目名称</span>
<span class="bill-detail__value">{{ currentBill.projectName }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">订单金额</span>
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(currentBill.amount) }}</span>
</div>
</div>
<!-- 待支付:立即支付 -->
<div v-if="currentBill.status === '待支付'" class="bill-detail__pay">
<AppButton type="primary" @click="onPay">
立即支付
</AppButton>
</div>
<!-- 已支付:支付信息 -->
<template v-else-if="currentBill.status === '已支付'">
<div class="bill-detail__pay-info">
<h4 class="bill-detail__sub-title">
支付信息
</h4>
<div class="bill-detail__fields">
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">付款金额</span>
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(financeStore.result?.amount ?? currentBill.amount) }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付方式</span>
<span class="bill-detail__value">微信支付</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付时间</span>
<span class="bill-detail__value">{{ financeStore.result?.payTime ?? '—' }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">交易流水号</span>
<span class="bill-detail__value">{{ financeStore.result?.tradeNo ?? currentBill.tradeNo ?? '—' }}</span>
</div>
</div>
</div>
</template>
<!-- 未到期:提示 -->
<div v-else class="bill-detail__tip text-secondary">
账单尚未到期,到期后可前往账单明细完成支付。
</div>
</section>
</template>
<p v-else class="bill-detail__empty text-secondary">
账单不存在或已下线
</p>
<div class="bill-detail__back">
<AppButton @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.bill-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__card {
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__card-head {
.flex(row, space-between, center);
margin-bottom: @spacing-base;
}
&__card-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__fields {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__field {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
}
&__label {
width: 80px;
flex-shrink: 0;
}
&__value {
color: @heading-color;
&--amount {
font-weight: 600;
color: @danger-color;
}
}
&__pay {
margin-top: @spacing-lg;
}
&__pay-info {
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__tip {
margin-top: @spacing-lg;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color;
font-size: @font-size-base;
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
font-size: @font-size-base;
}
&__back {
margin-top: @spacing-lg;
}
}
</style>

158
src/views/finance/bills.vue Normal file
View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { FilterPanel, PaginationBar } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance'
import BillSummaryBar from './cpns/BillSummaryBar.vue'
import BillTable from './cpns/BillTable.vue'
const router = useRouter()
const financeStore = useFinanceStore()
const { query, list, total } = storeToRefs(financeStore)
const keyword = ref('')
const selectedIds = ref<string[]>([])
const STATUS_TABS: Array<{ label: string; value: BillStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待支付', value: '待支付' },
{ label: '已支付', value: '已支付' },
{ label: '未到期', value: '未到期' },
]
function onTab(value: BillStatus | 'all'): void {
if (query.value.status === value) return
financeStore.setQuery({ ...query.value, status: value, keyword: keyword.value })
selectedIds.value = []
void financeStore.search()
}
function onSearch(): void {
financeStore.setQuery({ ...query.value, keyword: keyword.value })
void financeStore.search()
}
function onReset(): void {
keyword.value = ''
selectedIds.value = []
void financeStore.resetQuery()
}
function onPageChange(page: number): void {
void financeStore.changePage(page)
}
function onView(id: string): void {
void router.push(`/finance/bills/${id}`)
}
function onPay(id: string): void {
void router.push(`/finance/bills/${id}/pay`)
}
function onBatchPay(ids: string[]): void {
const first = ids[0]
if (!first) return
void router.push(`/finance/bills/${first}/pay`)
}
onMounted(() => {
keyword.value = query.value.keyword
void financeStore.fetchList()
})
</script>
<template>
<div class="bills">
<nav class="bills__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>账单明细</span>
</nav>
<div class="bills__tabs">
<button
v-for="tab in STATUS_TABS"
:key="tab.value"
class="bills__tab"
:class="{ 'is-active': query.status === tab.value }"
@click="onTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索账单名称 / 账单号 / 项目名称"
@search="onSearch"
@reset="onReset"
/>
<BillTable
:bills="list"
:selected-ids="selectedIds"
@update:selection="selectedIds = $event"
@view="onView"
@pay="onPay"
/>
<BillSummaryBar
:bills="list"
:selected-ids="selectedIds"
@batch-pay="onBatchPay"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.bills {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AppButton } from '@/components'
import type { Bill } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
bills: Bill[]
selectedIds: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{ batchPay: [ids: string[]] }>()
const totalCount = computed(() => props.bills.length)
const totalAmount = computed(() => props.bills.reduce((sum, bill) => sum + bill.amount, 0))
const pendingCount = computed(() => props.bills.filter((bill) => bill.status === '待支付').length)
const selectedBills = computed(() => props.bills.filter((bill) => props.selectedIds.includes(bill.id)))
const selectedAmount = computed(() =>
selectedBills.value.reduce((sum, bill) => sum + bill.amount, 0),
)
const canPay = computed(() => selectedBills.value.length > 0)
function onBatchPay(): void {
if (!canPay.value) return
emit('batchPay', selectedBills.value.map((bill) => bill.id))
}
</script>
<template>
<div class="bill-summary">
<div class="bill-summary__stats">
<span class="bill-summary__stat">总笔数:<strong>{{ totalCount }}</strong></span>
<span class="bill-summary__stat">总金额:<strong>¥{{ formatMoney(totalAmount) }}</strong></span>
<span class="bill-summary__stat">待支付:<strong>{{ pendingCount }}</strong></span>
</div>
<div class="bill-summary__right">
<span class="bill-summary__selected">
已选择{{ selectedBills.length }}笔订单 · 合计:¥{{ formatMoney(selectedAmount) }}
</span>
<AppButton
type="primary"
:disabled="!canPay"
@click="onBatchPay"
>
立即支付
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.bill-summary {
.flex(row, space-between, center);
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__stats {
.flex(row, flex-start);
gap: @spacing-lg;
font-size: @font-size-base;
color: @text-color;
strong {
color: @heading-color;
}
}
&__right {
.flex(row, flex-end, center);
gap: @spacing-base;
}
&__selected {
font-size: @font-size-base;
color: @text-color;
}
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { StatusTag } from '@/components'
import type { Bill, BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
bills: Bill[]
selectedIds: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
pay: [id: string]
'update:selection': [ids: string[]]
}>()
/** 本地勾选集合(由 selectedIds prop 同步) */
const selection = ref<Set<string>>(new Set(props.selectedIds))
watch(
() => props.selectedIds,
(ids) => {
selection.value = new Set(ids)
},
)
function toggle(id: string): void {
if (selection.value.has(id)) selection.value.delete(id)
else selection.value.add(id)
emit('update:selection', [...selection.value])
}
function toggleAll(): void {
const allSelected = props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
if (allSelected) {
for (const bill of props.bills) selection.value.delete(bill.id)
}
else {
for (const bill of props.bills) selection.value.add(bill.id)
}
emit('update:selection', [...selection.value])
}
const allSelected = () => props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
}
</script>
<template>
<div class="bill-table card">
<table class="bill-table__table">
<thead>
<tr>
<th class="bill-table__check">
<input
type="checkbox"
:checked="allSelected()"
@change="toggleAll"
>
</th>
<th>序号</th>
<th>账单名称</th>
<th>账单号</th>
<th>交易流水号</th>
<th>项目名称</th>
<th>订单金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(bill, index) in bills" :key="bill.id">
<td class="bill-table__check">
<input
type="checkbox"
:checked="selection.has(bill.id)"
@change="toggle(bill.id)"
>
</td>
<td>{{ index + 1 }}</td>
<td>{{ bill.planName }}</td>
<td>{{ bill.billNo }}</td>
<td>{{ bill.tradeNo ?? '—' }}</td>
<td>{{ bill.projectName }}</td>
<td class="bill-table__amount">¥{{ formatMoney(bill.amount) }}</td>
<td>
<StatusTag :label="bill.status" :variant="STATUS_VARIANTS[bill.status]" />
</td>
<td class="bill-table__ops">
<button
v-if="bill.status === '待支付'"
class="bill-table__link"
@click="emit('pay', bill.id)"
>
去支付
</button>
<button class="bill-table__link" @click="emit('view', bill.id)">
详情
</button>
</td>
</tr>
<tr v-if="bills.length === 0">
<td colspan="9" class="bill-table__empty text-secondary">
暂无账单数据
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="less" scoped>
.bill-table {
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow-x: auto;
&__table {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
color: @text-color;
th,
td {
padding: @spacing-sm @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary;
font-weight: 500;
}
tbody tr:hover {
background: @bg-color-light;
}
}
&__check {
width: 40px;
text-align: center;
}
&__amount {
color: @heading-color;
font-weight: 600;
}
&__ops {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__empty {
text-align: center;
padding: @spacing-xl 0;
}
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
interface DistributionItem {
name: string
value: number
color: string
}
interface Props {
distribution: DistributionItem[]
}
const props = defineProps<Props>()
const total = computed(() => props.distribution.reduce((sum, item) => sum + item.value, 0))
/** CSS conic-gradient(无图表库):按 value 占比切分色环 */
const gradient = computed(() => {
if (total.value <= 0) return 'conic-gradient(#e4e7ec 0% 100%)'
let acc = 0
const stops = props.distribution.map((item) => {
const start = acc
acc += (item.value / total.value) * 100
return `${item.color} ${start}% ${acc}%`
})
return `conic-gradient(${stops.join(', ')})`
})
function percentOf(value: number): number {
if (total.value <= 0) return 0
return Math.round((value / total.value) * 100)
}
</script>
<template>
<div class="finance-chart card">
<h3 class="finance-chart__title">
业务类型分布
</h3>
<div class="finance-chart__body">
<div class="finance-chart__pie" :style="{ background: gradient }" />
<ul class="finance-chart__legend">
<li
v-for="item in distribution"
:key="item.name"
class="finance-chart__legend-item"
>
<span
class="finance-chart__dot"
:style="{ background: item.color }"
/>
<span class="finance-chart__name">{{ item.name }}</span>
<span class="finance-chart__value">{{ percentOf(item.value) }}%</span>
</li>
</ul>
</div>
</div>
</template>
<style lang="less" scoped>
.finance-chart {
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__body {
.flex(row, center, center);
gap: @spacing-xl;
}
&__pie {
width: 180px;
height: 180px;
border-radius: @radius-round;
flex-shrink: 0;
}
&__legend {
.flex(column, flex-start);
gap: @spacing-base;
}
&__legend-item {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
}
&__dot {
width: 10px;
height: 10px;
border-radius: @radius-round;
flex-shrink: 0;
}
&__name {
min-width: 72px;
}
&__value {
color: @heading-color;
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FinanceStats } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
stats: FinanceStats | null
}
defineProps<Props>()
const emit = defineEmits<{
periodChange: [period: string]
export: []
}>()
</script>
<template>
<div class="finance-overview card">
<div class="finance-overview__main">
<p class="finance-overview__label text-secondary">
累计消费总额
</p>
<p class="finance-overview__amount">
¥{{ formatMoney(stats?.totalExpense ?? 0) }}
</p>
<div class="finance-overview__meta">
<div class="finance-overview__periods">
<button
class="finance-overview__period"
@click="emit('periodChange', 'quarter')"
>
本季度
</button>
<button
class="finance-overview__period"
@click="emit('periodChange', 'year')"
>
本年
</button>
</div>
<span class="finance-overview__month text-secondary">{{ stats?.month }}</span>
<StatusTag
v-if="(stats?.pendingCount ?? 0) > 0"
:label="`待支付 ${stats?.pendingCount ?? 0}`"
variant="warning"
/>
</div>
</div>
<div class="finance-overview__actions">
<RouterLink to="/finance/invoices" class="finance-overview__action">
发票申请
</RouterLink>
<RouterLink to="/finance/bills" class="finance-overview__action">
账单明细
</RouterLink>
<button class="finance-overview__action" @click="emit('export')">
报表导出
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.finance-overview {
.flex(row, space-between, center);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__main {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__label {
font-size: @font-size-base;
}
&__amount {
font-size: 36px;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__meta {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
}
&__periods {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__period {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
&__month {
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-base;
}
&__action {
font-size: @font-size-base;
color: @primary-color;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { InvoiceTitle } from '@/types/business/finance'
interface Props {
invoiceTitle: InvoiceTitle
}
defineProps<Props>()
const emit = defineEmits<{
setDefault: [id: string]
edit: [item: InvoiceTitle]
delete: [id: string]
}>()
</script>
<template>
<div class="invoice-title-card">
<div class="invoice-title-card__head">
<span class="invoice-title-card__label text-secondary">发票抬头</span>
<StatusTag
:label="invoiceTitle.type"
:variant="invoiceTitle.type === '企业单位' ? 'primary' : 'info'"
/>
<StatusTag v-if="invoiceTitle.isDefault" label="默认" variant="success" />
</div>
<p class="invoice-title-card__title">
{{ invoiceTitle.title }}
</p>
<div class="invoice-title-card__tax">
<span class="invoice-title-card__label text-secondary">税号</span>
<span class="invoice-title-card__tax-no">{{ invoiceTitle.taxNo }}</span>
</div>
<div class="invoice-title-card__actions">
<button
class="invoice-title-card__btn"
@click="emit('setDefault', invoiceTitle.id)"
>
{{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }}
</button>
<button class="invoice-title-card__btn" @click="emit('edit', invoiceTitle)">
编辑
</button>
<button class="invoice-title-card__btn is-danger" @click="emit('delete', invoiceTitle.id)">
删除
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.invoice-title-card {
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
.flex(column, flex-start);
gap: @spacing-sm;
&__head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__label {
font-size: @font-size-sm;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
word-break: break-all;
}
&__tax {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__tax-no {
font-size: @font-size-base;
color: @text-color;
word-break: break-all;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-sm;
margin-top: @spacing-xs;
}
&__btn {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid @border-color-dark;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: @primary-color;
color: @primary-color;
}
&.is-danger:hover:not(:disabled) {
border-color: @danger-color;
color: @danger-color;
}
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
interface Props {
invoiceTitle?: InvoiceTitle
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ save: [payload: InvoiceTitlePayload] }>()
const form = reactive<InvoiceTitlePayload>({
type: '企业单位',
title: '',
taxNo: '',
})
watch(visible, (open) => {
if (!open) return
form.type = props.invoiceTitle?.type ?? '企业单位'
form.title = props.invoiceTitle?.title ?? ''
form.taxNo = props.invoiceTitle?.taxNo ?? ''
})
function onSave(): void {
if (!form.title.trim() || !form.taxNo.trim()) {
ElMessage.warning('请填写发票抬头与税号')
return
}
emit('save', { type: form.type, title: form.title.trim(), taxNo: form.taxNo.trim() })
visible.value = false
}
function onCancel(): void {
visible.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="title-dialog" @click.self="onCancel">
<div class="title-dialog__panel">
<h3 class="title-dialog__title">
{{ invoiceTitle ? '编辑抬头' : '新增抬头' }}
</h3>
<div class="title-dialog__field">
<span class="title-dialog__label">抬头类型</span>
<label class="title-dialog__radio">
<input v-model="form.type" type="radio" value="企业单位">
企业单位
</label>
<label class="title-dialog__radio">
<input v-model="form.type" type="radio" value="非企业单位">
非企业单位
</label>
</div>
<div class="title-dialog__field">
<span class="title-dialog__label">发票抬头</span>
<input
v-model="form.title"
class="title-dialog__input"
placeholder="请输入发票抬头"
>
</div>
<div class="title-dialog__field">
<span class="title-dialog__label">税号</span>
<input
v-model="form.taxNo"
class="title-dialog__input"
placeholder="请输入税号"
>
</div>
<div class="title-dialog__footer">
<button class="title-dialog__btn" @click="onCancel">
取消
</button>
<button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave">
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.title-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 440px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__field {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__label {
width: 72px;
flex-shrink: 0;
font-size: @font-size-base;
color: @text-color;
}
&__radio {
.flex(row, flex-start, center);
gap: @spacing-xs;
font-size: @font-size-base;
color: @text-color;
cursor: pointer;
input {
accent-color: @primary-color;
}
}
&__input {
flex: 1;
height: 34px;
padding: 0 @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
margin-top: @spacing-lg;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&--confirm {
border-color: @primary-color;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
color: @white;
}
}
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { PayMethod } from '@/types/business/finance'
interface Props {
method: PayMethod
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ confirm: [method: PayMethod] }>()
const selected = ref<PayMethod>(props.method)
watch(
() => props.method,
(value) => {
selected.value = value
},
)
watch(visible, (open) => {
if (open) selected.value = props.method
})
function onConfirm(): void {
emit('confirm', selected.value)
visible.value = false
}
function onCancel(): void {
visible.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="pay-method-dialog" @click.self="onCancel">
<div class="pay-method-dialog__panel">
<h3 class="pay-method-dialog__title">
切换支付方式
</h3>
<label
class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'wechat' }"
>
<input
v-model="selected"
type="radio"
value="wechat"
class="pay-method-dialog__radio"
>
<span class="pay-method-dialog__name">微信支付</span>
<span class="pay-method-dialog__tip text-secondary">推荐使用</span>
</label>
<label
class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'alipay' }"
>
<input
v-model="selected"
type="radio"
value="alipay"
class="pay-method-dialog__radio"
>
<span class="pay-method-dialog__name">支付宝支付</span>
<span class="pay-method-dialog__tip text-secondary">其他用户选择</span>
</label>
<div class="pay-method-dialog__footer">
<button class="pay-method-dialog__btn" @click="onCancel">
取消
</button>
<button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm">
确认
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.pay-method-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 420px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__option {
.flex(row, flex-start, center);
gap: @spacing-sm;
padding: @spacing-base;
margin-bottom: @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-base;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
}
}
&__radio {
accent-color: @primary-color;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
font-weight: 500;
}
&__tip {
margin-left: auto;
font-size: @font-size-sm;
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
margin-top: @spacing-base;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&--confirm {
border-color: @primary-color;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
color: @white;
}
}
}
}
</style>

View File

@@ -0,0 +1,8 @@
export { default } from './index.vue'
export { default as FinanceOverview } from './cpns/FinanceOverview.vue'
export { default as FinanceChart } from './cpns/FinanceChart.vue'
export { default as BillTable } from './cpns/BillTable.vue'
export { default as BillSummaryBar } from './cpns/BillSummaryBar.vue'
export { default as PayMethodDialog } from './cpns/PayMethodDialog.vue'
export { default as InvoiceTitleCard } from './cpns/InvoiceTitleCard.vue'
export { default as InvoiceTitleDialog } from './cpns/InvoiceTitleDialog.vue'

110
src/views/finance/index.vue Normal file
View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus'
// 页面容器:只做组件组合与事件中转,数据只经 store
import { useFinanceStore } from '@/stores/modules/finance'
import FinanceChart from './cpns/FinanceChart.vue'
import FinanceOverview from './cpns/FinanceOverview.vue'
const financeStore = useFinanceStore()
const { stats } = storeToRefs(financeStore)
function onPeriodChange(period: string): void {
void financeStore.fetchStats(period)
}
async function onExport(): Promise<void> {
await financeStore.export()
ElMessage.success('报表导出成功(演示)')
}
onMounted(() => {
void financeStore.fetchStats()
})
</script>
<template>
<div class="finance">
<header class="finance__head">
<h1 class="finance__title">
财务中心
</h1>
<p class="finance__slogan text-secondary">
账单支付、发票申请与报表导出一站式管理。
</p>
</header>
<FinanceOverview
:stats="stats"
@period-change="onPeriodChange"
@export="onExport"
/>
<FinanceChart :distribution="stats?.distribution ?? []" />
<div class="finance__entries">
<RouterLink to="/finance/invoices" class="finance__entry">
<span class="finance__entry-icon">🧾</span>
<span class="finance__entry-name">发票申请</span>
</RouterLink>
<RouterLink to="/finance/bills" class="finance__entry">
<span class="finance__entry-icon">📋</span>
<span class="finance__entry-name">账单明细</span>
</RouterLink>
<button class="finance__entry" @click="onExport">
<span class="finance__entry-icon">📊</span>
<span class="finance__entry-name">报表导出</span>
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.finance {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
&__entries {
.flex(row, flex-start);
gap: @spacing-base;
}
&__entry {
.flex(row, center, center);
gap: @spacing-sm;
width: 200px;
height: 72px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-lg;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
&__entry-icon {
font-size: @font-size-xl;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { AppButton, ConfirmDialog } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
import InvoiceTitleCard from './cpns/InvoiceTitleCard.vue'
import InvoiceTitleDialog from './cpns/InvoiceTitleDialog.vue'
const financeStore = useFinanceStore()
const { titles } = storeToRefs(financeStore)
type TypeFilter = 'all' | InvoiceTitle['type']
const typeFilter = ref<TypeFilter>('all')
const TYPE_TABS: Array<{ label: string; value: TypeFilter }> = [
{ label: '全部', value: 'all' },
{ label: '企业单位', value: '企业单位' },
{ label: '非企业单位', value: '非企业单位' },
]
const filteredTitles = computed(() =>
typeFilter.value === 'all'
? titles.value
: titles.value.filter((item) => item.type === typeFilter.value),
)
const dialogVisible = ref(false)
const editing = ref<InvoiceTitle | undefined>(undefined)
const confirmVisible = ref(false)
const pendingDeleteId = ref('')
function onCreate(): void {
editing.value = undefined
dialogVisible.value = true
}
function onEdit(item: InvoiceTitle): void {
editing.value = item
dialogVisible.value = true
}
async function onSave(payload: InvoiceTitlePayload): Promise<void> {
if (editing.value) {
await financeStore.updateTitle(editing.value.id, payload)
ElMessage.success('抬头已更新')
}
else {
await financeStore.createTitle(payload)
ElMessage.success('抬头已新增')
}
}
function onDelete(id: string): void {
pendingDeleteId.value = id
confirmVisible.value = true
}
async function onConfirmDelete(): Promise<void> {
await financeStore.deleteTitle(pendingDeleteId.value)
pendingDeleteId.value = ''
ElMessage.success('抬头已删除')
}
async function onSetDefault(id: string): Promise<void> {
const item = titles.value.find((title) => title.id === id)
if (!item) return
if (item.isDefault) {
ElMessage.info('演示环境暂不支持取消默认,可设其他抬头为默认')
return
}
await financeStore.setDefaultTitle(id)
ElMessage.success('已设为默认抬头')
}
onMounted(() => {
void financeStore.fetchTitles()
})
</script>
<template>
<div class="invoice-titles">
<nav class="invoice-titles__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>发票抬头</span>
</nav>
<header class="invoice-titles__head">
<h2 class="invoice-titles__title">
发票管理
</h2>
<div class="invoice-titles__filters">
<button
v-for="tab in TYPE_TABS"
:key="tab.value"
class="invoice-titles__filter"
:class="{ 'is-active': typeFilter === tab.value }"
@click="typeFilter = tab.value"
>
{{ tab.label }}
</button>
<AppButton type="primary" @click="onCreate">
+新增抬头
</AppButton>
</div>
</header>
<div class="invoice-titles__grid">
<InvoiceTitleCard
v-for="item in filteredTitles"
:key="item.id"
:invoice-title="item"
@set-default="onSetDefault"
@edit="onEdit"
@delete="onDelete"
/>
<p v-if="filteredTitles.length === 0" class="invoice-titles__empty text-secondary">
暂无发票抬头,点击右上角新增
</p>
</div>
<InvoiceTitleDialog
v-model:visible="dialogVisible"
:invoice-title="editing"
@save="onSave"
/>
<ConfirmDialog
v-model:visible="confirmVisible"
title="删除抬头"
message="删除后无法恢复,是否继续?"
confirm-text="删除"
danger
@confirm="onConfirmDelete"
/>
</div>
</template>
<style lang="less" scoped>
.invoice-titles {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__head {
.flex(row, space-between, center);
flex-wrap: wrap;
gap: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__filters {
.flex(row, flex-end, center);
gap: @spacing-sm;
}
&__filter {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
&__grid {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__empty {
padding: @spacing-xl 0;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus'
import { FilterPanel, PaginationBar, StatusTag } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { Invoice, InvoiceStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
const financeStore = useFinanceStore()
const { titles, invoices, invoiceQuery, invoiceTotal } = storeToRefs(financeStore)
const keyword = ref('')
const selectedIds = ref<string[]>([])
const defaultTitle = computed(() => titles.value.find((item) => item.isDefault) ?? titles.value[0] ?? null)
const STATUS_TABS: Array<{ label: string; value: InvoiceStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '已开票', value: '已开票' },
{ label: '开票中', value: '开票中' },
{ label: '未开票', value: '未开票' },
]
const STATUS_VARIANTS: Record<InvoiceStatus, 'success' | 'warning' | 'info'> = {
已开票: 'success',
开票中: 'warning',
未开票: 'info',
}
function onTab(value: InvoiceStatus | 'all'): void {
if (invoiceQuery.value.status === value) return
financeStore.setInvoiceQuery({ ...invoiceQuery.value, status: value, keyword: keyword.value })
selectedIds.value = []
void financeStore.searchInvoices()
}
function onSearch(): void {
financeStore.setInvoiceQuery({ ...invoiceQuery.value, keyword: keyword.value })
void financeStore.searchInvoices()
}
function onReset(): void {
keyword.value = ''
selectedIds.value = []
financeStore.setInvoiceQuery({ keyword: '', status: 'all', page: 1, pageSize: invoiceQuery.value.pageSize })
void financeStore.fetchInvoices()
}
function onPageChange(page: number): void {
void financeStore.changeInvoicePage(page)
}
async function onApply(invoice: Invoice): Promise<void> {
await financeStore.applyInvoice(invoice.id)
ElMessage.success('开票申请已提交')
}
function onPreview(): void {
ElMessage.info('附件待接口接入')
}
function onDownload(): void {
ElMessage.info('附件待接口接入')
}
function onSendEmail(): void {
ElMessage.success('发票已发送至绑定邮箱(演示)')
}
function onToggleSelect(id: string): void {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((item) => item !== id)
}
else {
selectedIds.value = [...selectedIds.value, id]
}
}
onMounted(() => {
keyword.value = invoiceQuery.value.keyword
void Promise.all([financeStore.fetchTitles(), financeStore.fetchInvoices()])
})
</script>
<template>
<div class="invoices">
<nav class="invoices__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>发票申请</span>
</nav>
<section v-if="defaultTitle" class="invoices__title-card card">
<div class="invoices__title-main">
<div class="invoices__title-head">
<span class="invoices__title-label text-secondary">默认发票抬头</span>
<StatusTag :label="defaultTitle.type" variant="primary" />
<StatusTag label="默认" variant="success" />
</div>
<p class="invoices__title-name">
{{ defaultTitle.title }}
</p>
<p class="invoices__title-tax text-secondary">
税号:{{ defaultTitle.taxNo }}
</p>
</div>
<RouterLink to="/finance/invoice-titles" class="invoices__title-manage">
管理抬头 &gt;
</RouterLink>
</section>
<div class="invoices__tabs">
<button
v-for="tab in STATUS_TABS"
:key="tab.value"
class="invoices__tab"
:class="{ 'is-active': invoiceQuery.status === tab.value }"
@click="onTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索发票抬头 / 账单号 / 项目名称"
@search="onSearch"
@reset="onReset"
/>
<div class="invoices__table card">
<table class="invoices__table-el">
<thead>
<tr>
<th class="invoices__check">
多选
</th>
<th>序号</th>
<th>账单名称</th>
<th>账单号</th>
<th>交易流水号</th>
<th>项目名称</th>
<th>订单金额</th>
<th>状态</th>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(invoice, index) in invoices" :key="invoice.id">
<td class="invoices__check">
<input
type="checkbox"
:checked="selectedIds.includes(invoice.id)"
@change="onToggleSelect(invoice.id)"
>
</td>
<td>{{ index + 1 }}</td>
<td>{{ invoice.planName }}</td>
<td>{{ invoice.billNo }}</td>
<td>{{ invoice.tradeNo ?? '—' }}</td>
<td>{{ invoice.projectName }}</td>
<td class="invoices__amount">¥{{ formatMoney(invoice.amount) }}</td>
<td>
<StatusTag :label="invoice.status" :variant="STATUS_VARIANTS[invoice.status]" />
</td>
<td>{{ invoice.applyTime }}</td>
<td class="invoices__ops">
<template v-if="invoice.status === '已开票'">
<button class="invoices__link" @click="onPreview">
预览
</button>
<button class="invoices__link" @click="onDownload">
下载
</button>
<button class="invoices__link" @click="onSendEmail">
发送邮箱
</button>
</template>
<span v-else-if="invoice.status === '开票中'" class="invoices__dash">—</span>
<button v-else class="invoices__link" @click="onApply(invoice)">
申请开票
</button>
</td>
</tr>
<tr v-if="invoices.length === 0">
<td colspan="10" class="invoices__empty text-secondary">
暂无发票记录
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="invoiceQuery.page"
:page-size="invoiceQuery.pageSize"
:total="invoiceTotal"
@page-change="onPageChange"
/>
<p class="invoices__tip text-secondary">
开票将使用默认抬头,如需调整请前往 <RouterLink to="/finance/invoice-titles" class="link">管理抬头</RouterLink>。
</p>
</div>
</template>
<style lang="less" scoped>
.invoices {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__title-card {
.flex(row, space-between, center);
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title-main {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__title-head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__title-label {
font-size: @font-size-sm;
}
&__title-name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__title-tax {
font-size: @font-size-base;
}
&__title-manage {
color: @primary-color;
font-size: @font-size-base;
white-space: nowrap;
&:hover {
color: @primary-hover;
}
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
&__table {
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow-x: auto;
}
&__table-el {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
color: @text-color;
th,
td {
padding: @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary;
font-weight: 500;
}
tbody tr:hover {
background: @bg-color-light;
}
}
&__check {
width: 48px;
text-align: center;
}
&__amount {
color: @heading-color;
font-weight: 600;
}
&__ops {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__dash {
color: @text-color-secondary;
}
&__empty {
text-align: center;
padding: @spacing-xl 0;
}
&__tip {
font-size: @font-size-sm;
}
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import { formatMoney } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { result } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const methodLabel = computed(() => {
if (!result.value) return '—'
return result.value.method === 'wechat' ? '微信支付' : '支付宝支付'
})
onMounted(() => {
void financeStore.fetchResult(billId.value)
})
</script>
<template>
<div class="pay-result">
<nav class="pay-result__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>支付结果</span>
</nav>
<template v-if="result">
<section class="pay-result__card card">
<div class="pay-result__head">
<span class="pay-result__check">✓</span>
<div class="pay-result__head-text">
<h2 class="pay-result__title">
支付成功
</h2>
<p class="pay-result__amount">
付款金额 ¥{{ formatMoney(result.amount) }}
</p>
<p class="pay-result__count text-secondary">
共{{ result.bills.length }}个订单
</p>
</div>
</div>
<div class="pay-result__grid">
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">支付方式</span>
<span class="pay-result__grid-value">{{ methodLabel }}</span>
</div>
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">支付时间</span>
<span class="pay-result__grid-value">{{ result.payTime }}</span>
</div>
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">交易流水号</span>
<span class="pay-result__grid-value">{{ result.tradeNo }}</span>
</div>
</div>
<div class="pay-result__bills">
<h4 class="pay-result__sub-title">
已支付订单
</h4>
<ul class="pay-result__bill-list">
<li
v-for="bill in result.bills"
:key="bill.id"
class="pay-result__bill"
>
<span class="pay-result__bill-name">{{ bill.planName }}</span>
<span class="pay-result__bill-no text-secondary">{{ bill.billNo }}</span>
<span class="pay-result__bill-amount">¥{{ formatMoney(bill.amount) }}</span>
</li>
</ul>
</div>
<div class="pay-result__footer">
<AppButton type="primary" @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</section>
</template>
<p v-else class="pay-result__empty text-secondary">
暂无支付结果
</p>
</div>
</template>
<style lang="less" scoped>
.pay-result {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__card {
padding: @spacing-xl @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
padding-bottom: @spacing-lg;
border-bottom: 1px solid @border-color;
}
&__check {
.flex(row, center);
width: 56px;
height: 56px;
border-radius: @radius-round;
background: fade(@success-color, 12%);
color: @success-color;
font-size: 32px;
font-weight: 600;
flex-shrink: 0;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__amount {
margin-top: @spacing-xs;
font-size: @font-size-lg;
color: @danger-color;
font-weight: 600;
}
&__count {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
&__grid {
.flex(row, flex-start, flex-start);
gap: @spacing-xxl;
margin-top: @spacing-lg;
}
&__grid-item {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__grid-label {
font-size: @font-size-sm;
}
&__grid-value {
font-size: @font-size-base;
color: @heading-color;
word-break: break-all;
}
&__bills {
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__bill-list {
.flex(column, flex-start);
gap: @spacing-sm;
list-style: none;
padding: 0;
}
&__bill {
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-sm;
border-radius: @radius-sm;
background: @bg-color-light;
font-size: @font-size-base;
color: @text-color;
}
&__bill-name {
color: @heading-color;
}
&__bill-no {
flex: 1;
}
&__bill-amount {
font-weight: 600;
color: @heading-color;
}
&__footer {
margin-top: @spacing-lg;
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

291
src/views/finance/pay.vue Normal file
View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton, QrCodePanel } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { PayMethod } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
import PayMethodDialog from './cpns/PayMethodDialog.vue'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { payment, currentBill } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const dialogVisible = ref(false)
const method = computed<PayMethod>(() => payment.value?.method ?? 'wechat')
const methodLabel = computed(() => (method.value === 'wechat' ? '微信支付' : '支付宝支付'))
const steps = computed<string[]>(() =>
method.value === 'wechat'
? [
'打开手机微信',
'扫描左侧二维码',
'输入支付密码完成支付,本页将自动跳转至支付结果页',
]
: [
'打开手机支付宝',
'对准屏幕上方的二维码进行扫描,核对金额后确认支付',
'输入支付密码完成支付',
],
)
function onRefresh(): void {
void financeStore.switchPaymentMethod(billId.value, method.value)
ElMessage.success('二维码已刷新')
}
function onConfirmMethod(next: PayMethod): void {
void financeStore.switchPaymentMethod(billId.value, next)
ElMessage.success('二维码已刷新')
}
async function onPaid(): Promise<void> {
await financeStore.createPayment(billId.value, method.value)
void router.push(`/finance/bills/${billId.value}/pay/result`)
}
onMounted(() => {
void Promise.all([
financeStore.createPayment(billId.value, 'wechat'),
financeStore.fetchDetail(billId.value),
])
})
</script>
<template>
<div class="pay">
<nav class="pay__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>扫码支付</span>
</nav>
<div class="pay__body">
<section class="pay__left card">
<QrCodePanel
:expires-in="payment?.qrExpiresInSeconds ?? 300"
@refresh="onRefresh"
/>
<p class="pay__safe text-secondary">
安全提示:本页面已通过安全验证,请勿向他人泄露付款码
</p>
<AppButton @click="dialogVisible = true">
切换支付方式
</AppButton>
</section>
<section class="pay__right card">
<div class="pay__amount-block">
<p class="pay__amount-label text-secondary">
支付金额
</p>
<p class="pay__amount">¥{{ formatMoney(payment?.amount ?? currentBill?.amount ?? 0) }}</p>
<p class="pay__method">
{{ methodLabel }} · 请使用手机微信 <strong>[扫一扫]</strong> 扫描下方二维码完成支付
</p>
</div>
<div class="pay__detail">
<h4 class="pay__sub-title">
订单明细
</h4>
<ul class="pay__detail-list">
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">账单号</span>
<span>{{ currentBill?.billNo ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">关联项目</span>
<span>{{ currentBill?.projectName ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">期数</span>
<span>{{ currentBill?.planName ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">金额</span>
<span class="pay__detail-amount">¥{{ formatMoney(payment?.amount ?? currentBill?.amount ?? 0) }}</span>
</li>
</ul>
</div>
<div class="pay__steps">
<h4 class="pay__sub-title">
扫码支付操作步骤
</h4>
<ol class="pay__step-list">
<li v-for="(step, index) in steps" :key="step" class="pay__step">
<span class="pay__step-index">{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</div>
<div class="pay__footer">
<AppButton type="primary" size="lg" @click="onPaid">
完成支付
</AppButton>
<AppButton @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</section>
</div>
<PayMethodDialog
v-model:visible="dialogVisible"
:method="method"
@confirm="onConfirmMethod"
/>
</div>
</template>
<style lang="less" scoped>
.pay {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__body {
.flex(row, flex-start);
gap: @spacing-base;
align-items: flex-start;
}
&__left {
.flex(column, center, center);
gap: @spacing-base;
width: 320px;
flex-shrink: 0;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__safe {
font-size: @font-size-sm;
text-align: center;
line-height: 1.6;
}
&__right {
flex: 1;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__amount-block {
padding-bottom: @spacing-base;
border-bottom: 1px solid @border-color;
}
&__amount-label {
font-size: @font-size-base;
}
&__amount {
margin: @spacing-xs 0;
font-size: 32px;
font-weight: 600;
color: @danger-color;
line-height: 1.2;
}
&__method {
font-size: @font-size-base;
color: @text-color;
strong {
color: @primary-color;
}
}
&__detail,
&__steps {
margin-top: @spacing-base;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__detail-list {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__detail-item {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
color: @heading-color;
}
&__detail-label {
width: 72px;
flex-shrink: 0;
}
&__detail-amount {
font-weight: 600;
color: @danger-color;
}
&__step-list {
.flex(column, flex-start);
gap: @spacing-sm;
padding-left: 0;
list-style: none;
}
&__step {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
}
&__step-index {
.flex(row, center);
width: 22px;
height: 22px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
flex-shrink: 0;
}
&__footer {
.flex(row, flex-start, center);
gap: @spacing-base;
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { BusinessLine } from '@/types/business/home'
interface Props {
lines: BusinessLine[]
}
defineProps<Props>()
</script>
<template>
<section class="home-business">
<div class="home-business__head">
<h3 class="home-business__title">
业务服务
</h3>
<span class="home-business__slogan text-secondary">研发智造一体化自营平台,全链路质量兜底</span>
</div>
<div class="home-business__grid">
<RouterLink
v-for="line in lines"
:key="line.id"
:to="`/business/${line.id}`"
class="home-business__card"
>
<div class="home-business__icon">
<!-- TODO(蓝湖): 替换业务图标切图 -->
<span>{{ line.name.slice(0, 2) }}</span>
</div>
<h4 class="home-business__name">
{{ line.name }}
</h4>
<p class="home-business__desc text-secondary">
{{ line.desc }}
</p>
<span class="home-business__more">了解详细 ></span>
</RouterLink>
</div>
</section>
</template>
<style lang="less" scoped>
.home-business {
&__head {
.flex(row, space-between);
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
font-size: @font-size-sm;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
}
&__card {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
.home-business__more {
color: @primary-color;
}
}
}
&__icon {
.flex(row, center);
width: 48px;
height: 48px;
border-radius: @radius-base;
background: @primary-light;
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
min-height: 40px;
font-size: @font-size-sm;
.multi-ellipsis(2);
}
&__more {
font-size: @font-size-sm;
color: @text-color-secondary;
transition: color 0.2s;
}
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CaseCard } from '@/components'
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
interface Props {
cases: Case[]
}
defineProps<Props>()
const emit = defineEmits<{ categoryChange: [category: BusinessType | 'all'] }>()
const activeCategory = ref<BusinessType | 'all'>('all')
const categories: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
function switchCategory(value: BusinessType | 'all'): void {
activeCategory.value = value
emit('categoryChange', value)
}
</script>
<template>
<section class="home-cases">
<div class="home-cases__head">
<h3 class="home-cases__title">
案例库精选
</h3>
<div class="home-cases__tabs">
<button
v-for="cat in categories"
:key="cat.value"
class="home-cases__tab"
:class="{ 'is-active': activeCategory === cat.value }"
@click="switchCategory(cat.value)"
>
{{ cat.label }}
</button>
</div>
<RouterLink to="/cases" class="home-cases__more link">
查看更多 >
</RouterLink>
</div>
<div class="home-cases__grid">
<CaseCard
v-for="item in cases"
:key="item.id"
:item="item"
@view="$router.push(`/cases/${$event}`)"
/>
</div>
</section>
</template>
<style lang="less" scoped>
.home-cases {
&__head {
.flex(row, space-between);
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__tabs {
.flex(row, center);
gap: @spacing-sm;
}
&__tab {
height: 30px;
padding: 0 @spacing-base;
border-radius: @radius-round;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&.is-active {
background: @primary-color;
color: @white;
}
}
&__more {
font-size: @font-size-base;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
// 快捷入口为固定模块链接(纯 UI,静态数据)
const entries = [
{ label: '财务台账', path: '/finance/bills', icon: '💰' },
{ label: '合同台账', path: '/contract', icon: '📄' },
{ label: '客服工作台', path: '/service', icon: '💬' },
{ label: '案例库管理', path: '/cases', icon: '📁' },
{ label: '工单需求池', path: '/demand', icon: '📋' },
]
</script>
<template>
<section class="home-quick">
<h3 class="home-quick__title">
快捷入口
</h3>
<div class="home-quick__list">
<RouterLink
v-for="entry in entries"
:key="entry.label"
:to="entry.path"
class="home-quick__item"
>
<span class="home-quick__icon">{{ entry.icon }}</span>
<span class="home-quick__label">{{ entry.label }}</span>
</RouterLink>
</div>
</section>
</template>
<style lang="less" scoped>
.home-quick {
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(row, space-between);
gap: @spacing-base;
}
&__item {
.flex(column, center);
gap: @spacing-sm;
flex: 1;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
}
}
&__icon {
font-size: 24px;
}
&__label {
font-size: @font-size-base;
color: @text-color;
}
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ServiceType } from '@/types/business/home'
interface Props {
serviceTypes: ServiceType[]
serviceContent: string
}
defineProps<Props>()
const activeType = ref(0)
</script>
<template>
<section class="home-service">
<div class="home-service__head">
<h3 class="home-service__title">
服务内容
</h3>
<div class="home-service__types">
<button
v-for="(type, index) in serviceTypes"
:key="type.label"
class="home-service__type"
:class="{ 'is-active': activeType === index }"
@click="activeType = index"
>
{{ type.label }}
</button>
</div>
</div>
<p class="home-service__type-desc text-secondary">
{{ serviceTypes[activeType]?.desc }}
</p>
<div class="home-service__content">
<pre class="home-service__text">{{ serviceContent }}</pre>
<div class="home-service__video">
<!-- TODO(蓝湖): 接入真实视频源 -->
<span class="home-service__video-tip">视频将在预览时播放</span>
<span class="home-service__video-time">0:00 / 0:00</span>
</div>
</div>
</section>
</template>
<style lang="less" scoped>
.home-service {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between);
margin-bottom: @spacing-sm;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__types {
.flex(row, center);
gap: @spacing-sm;
}
&__type {
height: 30px;
padding: 0 @spacing-base;
border-radius: @radius-round;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&.is-active {
background: @primary-color;
color: @white;
}
}
&__type-desc {
margin-bottom: @spacing-base;
font-size: @font-size-sm;
}
&__content {
.flex(row, flex-start);
gap: @spacing-lg;
}
&__text {
flex: 1;
margin: 0;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color-light;
font-family: inherit;
font-size: @font-size-sm;
line-height: 1.8;
color: @text-color;
white-space: pre-wrap;
max-height: 320px;
overflow: auto;
.scrollbar();
}
&__video {
.flex(column, center);
gap: @spacing-sm;
width: 320px;
height: 200px;
border-radius: @radius-base;
background: @heading-color;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__video-time {
font-size: @font-size-sm;
color: @text-color-disabled;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { HomeStats } from '@/types/business/home'
interface Props {
stats: HomeStats
}
defineProps<Props>()
const items = [
{ key: 'pending', label: '待您处理' },
{ key: 'inProgress', label: '进行中' },
{ key: 'total', label: '累计项目' },
] as const
</script>
<template>
<section class="home-stats">
<div
v-for="item in items"
:key="item.key"
class="home-stats__item"
>
<span class="home-stats__value">{{ stats[item.key] }}</span>
<span class="home-stats__label text-secondary">{{ item.label }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.home-stats {
.flex(row, space-between);
gap: @spacing-base;
&__item {
.flex(column, center);
flex: 1;
gap: @spacing-xs;
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__value {
font-size: @font-size-xxl;
font-weight: 600;
color: @primary-color;
}
&__label {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
summary: string
}
defineProps<Props>()
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 11) return '早上好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
</script>
<template>
<section class="home-welcome">
<div class="home-welcome__info">
<h2 class="home-welcome__greeting">
{{ greeting }}
</h2>
<p class="home-welcome__summary text-secondary">
{{ summary }}
</p>
</div>
<div class="home-welcome__brand">
<!-- TODO(蓝湖): 替换头像切图 -->
<span class="home-welcome__avatar">王</span>
<span class="home-welcome__appName">虫子智造</span>
</div>
</section>
</template>
<style lang="less" scoped>
.home-welcome {
.flex(row, space-between);
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: linear-gradient(120deg, @primary-color, @primary-hover);
color: @white;
&__greeting {
font-size: @font-size-xxl;
font-weight: 600;
}
&__summary {
margin-top: @spacing-xs;
color: rgba(255, 255, 255, 0.85);
}
&__brand {
.flex(row, center);
gap: @spacing-sm;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
border-radius: @radius-round;
background: rgba(255, 255, 255, 0.25);
font-size: @font-size-lg;
}
&__appName {
font-size: @font-size-base;
}
}
</style>

108
src/views/home/detail.vue Normal file
View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
// 业务详情页:数据只经 useHomeStore,不直接 import api
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
const route = useRoute()
const homeStore = useHomeStore()
const line = computed(() => homeStore.currentBusiness)
const businessType = computed(() => route.params.type as BusinessType)
function load(): void {
void homeStore.fetchBusinessDetail(businessType.value)
}
watch(businessType, load)
onMounted(load)
</script>
<template>
<div class="business-detail">
<nav class="business-detail__breadcrumb text-secondary">
<RouterLink to="/" class="link">
首页
</RouterLink>
<span> &gt; </span>
<span>业务详情</span>
</nav>
<template v-if="line">
<header class="business-detail__hero">
<span class="business-detail__logo">
<!-- TODO(蓝湖): 替换业务图标切图 -->
{{ line.name.slice(0, 2) }}
</span>
<div class="business-detail__intro">
<h1 class="business-detail__name">
{{ line.name }}
</h1>
<p class="business-detail__slogan text-secondary">
{{ line.slogan }}
</p>
</div>
</header>
<HomeServiceTabs
:service-types="line.serviceTypes"
:service-content="line.serviceContent"
/>
</template>
<p v-else class="text-secondary">
正在加载业务详情...
</p>
</div>
</template>
<style lang="less" scoped>
.business-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__hero {
.flex(row, flex-start);
gap: @spacing-base;
margin-bottom: @spacing-xl;
padding: @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__logo {
.flex(row, center);
width: 56px;
height: 56px;
border-radius: @radius-lg;
background: @primary-light;
color: @primary-color;
font-size: @font-size-xl;
font-weight: 600;
}
&__intro {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__name {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
font-size: @font-size-base;
}
}
</style>

7
src/views/home/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { default } from './index.vue'
export { default as HomeWelcome } from './cpns/HomeWelcome.vue'
export { default as HomeStats } from './cpns/HomeStats.vue'
export { default as HomeQuickEntries } from './cpns/HomeQuickEntries.vue'
export { default as HomeBusinessCards } from './cpns/HomeBusinessCards.vue'
export { default as HomeCaseShowcase } from './cpns/HomeCaseShowcase.vue'
export { default as HomeServiceTabs } from './cpns/HomeServiceTabs.vue'

58
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeWelcome from './cpns/HomeWelcome.vue'
import HomeStats from './cpns/HomeStats.vue'
import HomeQuickEntries from './cpns/HomeQuickEntries.vue'
import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
const homeStore = useHomeStore()
const { stats, businessLines, featuredCases } = storeToRefs(homeStore)
const todaySummary = computed(() => `今天有${stats.value.inProgress}个项目正在进行`)
// 服务 Tab 展示互联网软件线内容(服务内容区默认锚点)
const serviceLine = computed(
() => businessLines.value.find((line) => line.id === 'internet') ?? businessLines.value[0],
)
function onCategoryChange(category: BusinessType | 'all'): void {
void homeStore.fetchFeaturedCases(category)
}
onMounted(() => {
void homeStore.fetchHomeData()
})
</script>
<template>
<div class="home">
<HomeWelcome :summary="todaySummary" />
<HomeStats :stats="stats" />
<HomeQuickEntries />
<HomeBusinessCards :lines="businessLines" />
<HomeCaseShowcase
:cases="featuredCases"
@category-change="onCategoryChange"
/>
<HomeServiceTabs
v-if="serviceLine"
:service-types="serviceLine.serviceTypes"
:service-content="serviceLine.serviceContent"
/>
</div>
</template>
<style lang="less" scoped>
.home {
.flex(column, flex-start);
gap: @spacing-xl;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
account: string
password: string
agree: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:account': [value: string]
'update:password': [value: string]
'update:agree': [value: boolean]
submit: []
}>()
const localAccount = computed({
get: () => props.account,
set: (value: string) => emit('update:account', value),
})
const localPassword = computed({
get: () => props.password,
set: (value: string) => emit('update:password', value),
})
const localAgree = computed({
get: () => props.agree,
set: (value: boolean) => emit('update:agree', value),
})
</script>
<template>
<form class="login-form" @submit.prevent="emit('submit')">
<div class="login-form__field">
<input
v-model="localAccount"
class="login-form__input"
type="text"
placeholder="请输入账号"
autocomplete="username"
>
</div>
<div class="login-form__field">
<input
v-model="localPassword"
class="login-form__input"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
>
</div>
<label class="login-form__agree">
<input v-model="localAgree" type="checkbox">
<span>已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<button class="login-form__submit" type="submit">
登 录
</button>
</form>
</template>
<style lang="less" scoped>
.login-form {
.flex(column, flex-start);
gap: @spacing-base;
&__field {
width: 100%;
}
&__input {
width: 100%;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__agree {
.flex(row, flex-start);
gap: @spacing-xs;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
input {
margin-top: 2px;
accent-color: @primary-color;
}
}
&__submit {
width: 100%;
height: 44px;
border: none;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
letter-spacing: 2px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { SmsCodeInput } from '@/components'
import { isPhone } from '@/utils/validate'
interface Props {
phone: string
code: string
agree: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:phone': [value: string]
'update:code': [value: string]
'update:agree': [value: boolean]
getCode: [phone: string]
submit: []
}>()
const localPhone = computed({
get: () => props.phone,
set: (value: string) => emit('update:phone', value),
})
const localCode = computed({
get: () => props.code,
set: (value: string) => emit('update:code', value),
})
const localAgree = computed({
get: () => props.agree,
set: (value: boolean) => emit('update:agree', value),
})
function onGetCode(): void {
if (!isPhone(props.phone)) {
ElMessage.warning('手机号格式不正确')
return
}
emit('getCode', props.phone)
}
function onSubmit(): void {
if (!isPhone(props.phone)) {
ElMessage.warning('手机号格式不正确')
return
}
if (!props.code) {
ElMessage.warning('请输入验证码')
return
}
emit('submit')
}
</script>
<template>
<form class="sms-login" @submit.prevent="onSubmit">
<div class="sms-login__field">
<input
v-model="localPhone"
class="sms-login__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
autocomplete="tel"
>
</div>
<div class="sms-login__field">
<SmsCodeInput v-model:code="localCode" @get-code="onGetCode" />
</div>
<label class="sms-login__agree">
<input v-model="localAgree" type="checkbox">
<span>已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<button class="sms-login__submit" type="submit">
登 录
</button>
</form>
</template>
<style lang="less" scoped>
.sms-login {
.flex(column, flex-start);
gap: @spacing-base;
&__field {
width: 100%;
}
&__input {
width: 100%;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__agree {
.flex(row, flex-start);
gap: @spacing-xs;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
input {
margin-top: 2px;
accent-color: @primary-color;
}
}
&__submit {
width: 100%;
height: 44px;
border: none;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
letter-spacing: 2px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
}
</style>

3
src/views/login/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default } from './index.vue'
export { default as LoginForm } from './cpns/LoginForm.vue'
export { default as SmsLoginForm } from './cpns/SmsLoginForm.vue'

232
src/views/login/index.vue Normal file
View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { isPhone } from '@/utils/validate'
import LoginForm from './cpns/LoginForm.vue'
import SmsLoginForm from './cpns/SmsLoginForm.vue'
type LoginTab = 'account' | 'sms'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const activeTab = ref<LoginTab>('account')
const account = ref('')
const password = ref('')
const phone = ref('')
const code = ref('')
const agree = ref(false)
function goAfterLogin(): void {
void router.push(String(route.query.redirect ?? '/'))
}
async function onAccountSubmit(): Promise<void> {
if (!account.value.trim() || !password.value) {
ElMessage.warning('请输入账号和密码')
return
}
if (!agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
const ok = await userStore.loginByAccount({ account: account.value.trim(), password: password.value })
if (ok) goAfterLogin()
}
async function onSmsSubmit(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
if (!code.value) {
ElMessage.warning('请输入验证码')
return
}
if (!agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
const ok = await userStore.loginBySms({ phone: phone.value, code: code.value })
if (ok) goAfterLogin()
}
async function onGetCode(target: string): Promise<void> {
if (!isPhone(target)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await userStore.sendSmsCode(target)
}
catch {
// 失败提示已由 store 统一弹出,此处仅避免未处理的 rejection
}
}
</script>
<template>
<div class="auth-page">
<aside class="auth-page__brand">
<div class="auth-page__brand-inner">
<p class="auth-page__logo">
虫子智造研发一体化平台
</p>
<p class="auth-page__slogan">
安全 · 高效 · 可控
</p>
<RouterLink class="auth-page__guest" to="/">
游客模式 →
</RouterLink>
</div>
</aside>
<main class="auth-page__main">
<div class="auth-page__card">
<div class="auth-page__tabs">
<button
class="auth-page__tab"
:class="{ 'is-active': activeTab === 'account' }"
@click="activeTab = 'account'"
>
账号密码登录
</button>
<button
class="auth-page__tab"
:class="{ 'is-active': activeTab === 'sms' }"
@click="activeTab = 'sms'"
>
手机验证码登录
</button>
</div>
<LoginForm
v-if="activeTab === 'account'"
v-model:account="account"
v-model:password="password"
v-model:agree="agree"
@submit="onAccountSubmit"
/>
<SmsLoginForm
v-else
v-model:phone="phone"
v-model:code="code"
v-model:agree="agree"
@get-code="onGetCode"
@submit="onSmsSubmit"
/>
<p class="auth-page__switch">
没有账号?<RouterLink class="auth-page__link" to="/register">立即注册</RouterLink>
</p>
</div>
</main>
</div>
</template>
<style lang="less" scoped>
.auth-page {
display: flex;
min-height: 100vh;
background: @bg-color;
&__brand {
flex: 0 0 44%;
.flex(row, center, center);
background: linear-gradient(135deg, @primary-color 0%, #1b4fd8 100%);
color: @white;
}
&__brand-inner {
.flex(column, flex-start);
gap: @spacing-lg;
padding: @spacing-xxl;
}
&__logo {
font-size: 32px;
font-weight: 700;
letter-spacing: 2px;
line-height: 1.4;
}
&__slogan {
font-size: @font-size-xl;
opacity: 0.85;
letter-spacing: 6px;
}
&__guest {
margin-top: @spacing-xl;
padding: @spacing-sm @spacing-base;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: @radius-base;
color: @white;
font-size: @font-size-base;
text-decoration: none;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
&__main {
flex: 1;
.flex(row, center, center);
padding: @spacing-xl;
}
&__card {
.flex(column, flex-start);
gap: @spacing-lg;
width: 100%;
max-width: 420px;
padding: @spacing-xl @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__tabs {
.flex(row, center);
gap: @spacing-lg;
border-bottom: 1px solid @border-color;
}
&__tab {
padding: @spacing-sm 0;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: @font-size-lg;
color: @text-color-secondary;
cursor: pointer;
transition: all 0.2s;
&.is-active {
border-bottom-color: @primary-color;
color: @primary-color;
font-weight: 600;
}
}
&__switch {
text-align: center;
font-size: @font-size-base;
color: @text-color-secondary;
}
&__link {
color: @primary-color;
&:hover {
color: @primary-hover;
}
}
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { PasswordStrength, SmsCodeInput } from '@/components'
import { useUserStore } from '@/stores/modules/user'
import { isPhone, isValidPassword } from '@/utils/validate'
const router = useRouter()
const userStore = useUserStore()
const phone = ref('')
const code = ref('')
const password = ref('')
const confirmPassword = ref('')
const agree = ref(false)
let redirectTimer: number | null = null
async function onGetCode(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await userStore.sendSmsCode(phone.value)
}
catch {
// 失败提示已由 store 统一弹出,此处仅避免未处理的 rejection
}
}
async function onRegister(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
if (!/^\d{6}$/.test(code.value)) {
ElMessage.warning('请输入6位验证码')
return
}
if (!isValidPassword(password.value)) {
ElMessage.warning('密码长度应为6-20位')
return
}
if (password.value !== confirmPassword.value) {
ElMessage.warning('两次输入的密码不一致')
return
}
if (!agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
const ok = await userStore.register({
phone: phone.value,
code: code.value,
password: password.value,
confirmPassword: confirmPassword.value,
agree: agree.value,
})
if (ok) {
redirectTimer = window.setTimeout(() => {
void router.push('/login')
}, 3000)
}
}
onBeforeUnmount(() => {
if (redirectTimer !== null) window.clearTimeout(redirectTimer)
})
</script>
<template>
<div class="auth-page">
<aside class="auth-page__brand">
<div class="auth-page__brand-inner">
<p class="auth-page__logo">
虫子智造研发一体化平台
</p>
<p class="auth-page__slogan">
安全 · 高效 · 可控
</p>
<RouterLink class="auth-page__guest" to="/">
游客模式 →
</RouterLink>
</div>
</aside>
<main class="auth-page__main">
<div class="auth-page__card">
<h1 class="auth-page__title">
注册账号
</h1>
<form class="auth-page__form" @submit.prevent="onRegister">
<input
v-model="phone"
class="auth-page__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
autocomplete="tel"
>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
<div class="auth-page__pwd-wrap">
<input
v-model="password"
class="auth-page__input"
type="password"
placeholder="设置密码(6-20位)"
autocomplete="new-password"
>
<PasswordStrength :password="password" />
</div>
<input
v-model="confirmPassword"
class="auth-page__input"
type="password"
placeholder="确认密码"
autocomplete="new-password"
>
<label class="auth-page__agree">
<input v-model="agree" type="checkbox">
<span>已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<button class="auth-page__submit" type="submit">
注 册
</button>
</form>
<p class="auth-page__switch">
已有账号?<RouterLink class="auth-page__link" to="/login">立即登录</RouterLink>
</p>
</div>
</main>
</div>
</template>
<style lang="less" scoped>
.auth-page {
display: flex;
min-height: 100vh;
background: @bg-color;
&__brand {
flex: 0 0 44%;
.flex(row, center, center);
background: linear-gradient(135deg, @primary-color 0%, #1b4fd8 100%);
color: @white;
}
&__brand-inner {
.flex(column, flex-start);
gap: @spacing-lg;
padding: @spacing-xxl;
}
&__logo {
font-size: 32px;
font-weight: 700;
letter-spacing: 2px;
line-height: 1.4;
}
&__slogan {
font-size: @font-size-xl;
opacity: 0.85;
letter-spacing: 6px;
}
&__guest {
margin-top: @spacing-xl;
padding: @spacing-sm @spacing-base;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: @radius-base;
color: @white;
font-size: @font-size-base;
text-decoration: none;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
&__main {
flex: 1;
.flex(row, center, center);
padding: @spacing-xl;
}
&__card {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
max-width: 420px;
padding: @spacing-xl @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__form {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
}
&__pwd-wrap {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__input {
width: 100%;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__agree {
.flex(row, flex-start);
gap: @spacing-xs;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
input {
margin-top: 2px;
accent-color: @primary-color;
}
}
&__submit {
width: 100%;
height: 44px;
border: none;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
letter-spacing: 2px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__switch {
text-align: center;
font-size: @font-size-base;
color: @text-color-secondary;
}
&__link {
color: @primary-color;
&:hover {
color: @primary-hover;
}
}
}
</style>

278
src/views/login/reset.vue Normal file
View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { SmsCodeInput } from '@/components'
import { useUserStore } from '@/stores/modules/user'
import { isPhone, isValidPassword } from '@/utils/validate'
const router = useRouter()
const userStore = useUserStore()
const phone = ref('')
const code = ref('')
const password = ref('')
const confirmPassword = ref('')
const agree = ref(false)
let redirectTimer: number | null = null
async function onGetCode(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await userStore.sendSmsCode(phone.value)
}
catch {
// 失败提示已由 store 统一弹出,此处仅避免未处理的 rejection
}
}
async function onReset(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
if (!/^\d{6}$/.test(code.value)) {
ElMessage.warning('请输入6位验证码')
return
}
if (!isValidPassword(password.value)) {
ElMessage.warning('密码长度应为6-20位')
return
}
if (password.value !== confirmPassword.value) {
ElMessage.warning('两次输入的密码不一致')
return
}
if (!agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
const ok = await userStore.resetPassword({
phone: phone.value,
code: code.value,
password: password.value,
confirmPassword: confirmPassword.value,
agree: agree.value,
})
if (ok) {
redirectTimer = window.setTimeout(() => {
void router.push('/login')
}, 3000)
}
}
onBeforeUnmount(() => {
if (redirectTimer !== null) window.clearTimeout(redirectTimer)
})
</script>
<template>
<div class="auth-page">
<aside class="auth-page__brand">
<div class="auth-page__brand-inner">
<p class="auth-page__logo">
虫子智造研发一体化平台
</p>
<p class="auth-page__slogan">
安全 · 高效 · 可控
</p>
<RouterLink class="auth-page__guest" to="/">
游客模式 →
</RouterLink>
</div>
</aside>
<main class="auth-page__main">
<div class="auth-page__card">
<h1 class="auth-page__title">
找回密码
</h1>
<form class="auth-page__form" @submit.prevent="onReset">
<input
v-model="phone"
class="auth-page__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
autocomplete="tel"
>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
<input
v-model="password"
class="auth-page__input"
type="password"
placeholder="新密码(6-20位)"
autocomplete="new-password"
>
<input
v-model="confirmPassword"
class="auth-page__input"
type="password"
placeholder="确认新密码"
autocomplete="new-password"
>
<label class="auth-page__agree">
<input v-model="agree" type="checkbox">
<span>已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<button class="auth-page__submit" type="submit">
确认修改
</button>
</form>
<p class="auth-page__switch">
想起密码了?<RouterLink class="auth-page__link" to="/login">返回登录</RouterLink>
</p>
</div>
</main>
</div>
</template>
<style lang="less" scoped>
.auth-page {
display: flex;
min-height: 100vh;
background: @bg-color;
&__brand {
flex: 0 0 44%;
.flex(row, center, center);
background: linear-gradient(135deg, @primary-color 0%, #1b4fd8 100%);
color: @white;
}
&__brand-inner {
.flex(column, flex-start);
gap: @spacing-lg;
padding: @spacing-xxl;
}
&__logo {
font-size: 32px;
font-weight: 700;
letter-spacing: 2px;
line-height: 1.4;
}
&__slogan {
font-size: @font-size-xl;
opacity: 0.85;
letter-spacing: 6px;
}
&__guest {
margin-top: @spacing-xl;
padding: @spacing-sm @spacing-base;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: @radius-base;
color: @white;
font-size: @font-size-base;
text-decoration: none;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
&__main {
flex: 1;
.flex(row, center, center);
padding: @spacing-xl;
}
&__card {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
max-width: 420px;
padding: @spacing-xl @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__form {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
}
&__input {
width: 100%;
height: 44px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__agree {
.flex(row, flex-start);
gap: @spacing-xs;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
input {
margin-top: 2px;
accent-color: @primary-color;
}
}
&__submit {
width: 100%;
height: 44px;
border: none;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
letter-spacing: 2px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__switch {
text-align: center;
font-size: @font-size-base;
color: @text-color-secondary;
}
&__link {
color: @primary-color;
&:hover {
color: @primary-hover;
}
}
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Message, MessageType } from '@/types/business/message'
interface Props {
message: Message
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
const emojiMap: Record<MessageType, string> = {
在线客服: '💬',
状态通知: '📢',
系统通知: '🔔',
}
</script>
<template>
<div
class="message-item"
:class="{ 'is-unread': !message.isRead }"
role="button"
tabindex="0"
@click="emit('view', message.id)"
@keydown.enter="emit('view', message.id)"
>
<span class="message-item__emoji">{{ emojiMap[message.type] }}</span>
<div class="message-item__body">
<div class="message-item__head">
<span class="message-item__title">{{ message.title }}</span>
<span class="message-item__time text-secondary">{{ message.publishTime }}</span>
</div>
<p class="message-item__summary text-secondary">
{{ message.summary }}
</p>
</div>
<span v-if="!message.isRead" class="message-item__dot" aria-label="未读" />
</div>
</template>
<style lang="less" scoped>
.message-item {
.flex(row, flex-start);
gap: @spacing-base;
padding: @spacing-base;
border-radius: @radius-base;
background: @white;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @bg-color-light;
}
&__emoji {
flex-shrink: 0;
font-size: @font-size-xl;
line-height: 1.4;
}
&__body {
.flex(column, flex-start);
gap: @spacing-xs;
flex: 1;
min-width: 0;
}
&__head {
.flex(row, space-between);
gap: @spacing-base;
width: 100%;
}
&__title {
font-size: @font-size-lg;
color: @heading-color;
.ellipsis();
}
&__time {
flex-shrink: 0;
font-size: @font-size-sm;
}
&__summary {
font-size: @font-size-base;
.ellipsis();
}
&__dot {
flex-shrink: 0;
align-self: center;
width: 8px;
height: 8px;
border-radius: @radius-round;
background: @danger-color;
}
&.is-unread {
.message-item__title {
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Message } from '@/types/business/message'
import MessageItem from './MessageItem.vue'
interface Props {
messages: Message[]
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
</script>
<template>
<div class="message-list">
<template v-if="messages.length > 0">
<MessageItem
v-for="item in messages"
:key="item.id"
:message="item"
@view="emit('view', $event)"
/>
</template>
<p v-else class="message-list__empty text-secondary">
暂无消息
</p>
</div>
</template>
<style lang="less" scoped>
.message-list {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-sm;
border-radius: @radius-lg;
background: @bg-color;
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { MessageType } from '@/types/business/message'
interface Counts {
all: number
在线客服: number
状态通知: number
系统通知: number
}
interface Props {
active: MessageType | 'all'
counts: Counts
}
defineProps<Props>()
const emit = defineEmits<{ tabChange: [type: MessageType | 'all'] }>()
const tabs: Array<{ label: string; value: MessageType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '在线客服', value: '在线客服' },
{ label: '状态通知', value: '状态通知' },
{ label: '系统通知', value: '系统通知' },
]
function countOf(value: MessageType | 'all', counts: Counts): number {
return counts[value]
}
</script>
<template>
<div class="message-tabs">
<button
v-for="tab in tabs"
:key="tab.value"
class="message-tabs__tab"
:class="{ 'is-active': active === tab.value }"
@click="emit('tabChange', tab.value)"
>
{{ tab.label }}
<span class="message-tabs__count">{{ countOf(tab.value, counts) }}</span>
</button>
</div>
</template>
<style lang="less" scoped>
.message-tabs {
.flex(row, flex-start);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__tab {
.flex(row, center);
gap: @spacing-xs;
height: 32px;
padding: 0 @spacing-base;
border-radius: @radius-round;
background: transparent;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
&__count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: @radius-round;
background: @bg-color;
color: @text-color-secondary;
font-size: @font-size-sm;
line-height: 18px;
text-align: center;
}
.is-active &__count {
background: @primary-color;
color: @white;
}
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useMessageStore } from '@/stores/modules/message'
import { formatFileSize } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const messageStore = useMessageStore()
const item = computed(() => messageStore.current)
const messageId = computed(() => String(route.params.id))
function load(): void {
void messageStore.fetchDetail(messageId.value)
}
function onBack(): void {
void router.push('/message')
}
function onPreview(): void {
ElMessage.info('附件待接口接入')
}
function onDownload(): void {
ElMessage.info('附件待接口接入')
}
watch(messageId, load)
onMounted(load)
</script>
<template>
<div class="message-detail">
<nav class="message-detail__breadcrumb text-secondary">
<RouterLink to="/message" class="link">
消息中心
</RouterLink>
<span> &gt; </span>
<span>{{ item ? `${item.type}详情` : '消息详情' }}</span>
</nav>
<template v-if="item">
<article class="message-detail__card">
<h1 class="message-detail__title">
{{ item.title }}
</h1>
<p class="message-detail__time text-secondary">
发布时间:{{ item.publishTime }}
</p>
<div class="message-detail__content">
{{ item.content }}
</div>
<section v-if="item.attachments.length > 0" class="message-detail__attachments">
<h2 class="message-detail__attachments-title">
附件({{ item.attachments.length }})
</h2>
<ul class="message-detail__file-list">
<li
v-for="(file, index) in item.attachments"
:key="`${file.name}-${index}`"
class="message-detail__file"
>
<div class="message-detail__file-info">
<span class="message-detail__file-name" :title="file.name">{{ file.name }}</span>
<span class="message-detail__file-meta text-secondary">
{{ formatFileSize(file.size) }} · 上传于{{ file.uploadTime }}
</span>
</div>
<div class="message-detail__file-actions">
<button class="message-detail__file-btn" @click="onPreview">
预览
</button>
<button class="message-detail__file-btn" @click="onDownload">
下载
</button>
</div>
</li>
</ul>
</section>
<div class="message-detail__actions">
<AppButton type="primary" @click="onBack">
返回
</AppButton>
</div>
</article>
</template>
<p v-else class="message-detail__empty text-secondary">
消息不存在
</p>
</div>
</template>
<style lang="less" scoped>
.message-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__card {
padding: @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__time {
margin-top: @spacing-sm;
font-size: @font-size-sm;
}
&__content {
margin-top: @spacing-lg;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
}
&__attachments {
margin-top: @spacing-lg;
&-title {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
}
&__file-list {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__file {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
list-style: none;
}
&__file-info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__file-name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__file-meta {
font-size: @font-size-sm;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__file-btn {
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
}
&__actions {
.flex(row, flex-start);
margin-top: @spacing-lg;
}
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export { default } from './index.vue'
export { default as MessageTabs } from './cpns/MessageTabs.vue'
export { default as MessageList } from './cpns/MessageList.vue'
export { default as MessageItem } from './cpns/MessageItem.vue'

127
src/views/message/index.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { AppButton, FilterPanel, PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useMessageStore } from '@/stores/modules/message'
import type { MessageType } from '@/types/business/message'
import MessageTabs from './cpns/MessageTabs.vue'
import MessageList from './cpns/MessageList.vue'
const router = useRouter()
const messageStore = useMessageStore()
const { query, list, total, unreadCount, tabCounts } = storeToRefs(messageStore)
const keyword = computed({
get: () => query.value.keyword,
set: (value: string) => messageStore.setQuery({ ...query.value, keyword: value }),
})
function onSearch(): void {
void messageStore.search()
}
function onReset(): void {
void messageStore.resetQuery()
}
function onPageChange(page: number): void {
void messageStore.changePage(page)
}
function onTabChange(type: MessageType | 'all'): void {
messageStore.setQuery({ ...query.value, type })
void messageStore.search()
}
function onView(id: string): void {
void router.push(`/message/${id}`)
}
function onMarkAllRead(): void {
void messageStore.markAllRead()
}
onMounted(() => {
void Promise.all([
messageStore.fetchList(),
messageStore.fetchUnreadCount(),
messageStore.fetchTabCounts(),
])
})
</script>
<template>
<div class="message">
<header class="message__head">
<div class="message__head-text">
<h1 class="message__title">
消息中心
</h1>
<p class="message__subtitle text-secondary">
统一接收项目进度、售后工单、账单待办等站内消息,重要业务动态实时提醒
</p>
</div>
<AppButton
type="default"
:disabled="unreadCount === 0"
@click="onMarkAllRead"
>
全部已读
</AppButton>
</header>
<MessageTabs
:active="query.type"
:counts="tabCounts"
@tab-change="onTabChange"
/>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索消息标题、内容"
@search="onSearch"
@reset="onReset"
/>
<MessageList :messages="list" @view="onView" />
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.message {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__head {
.flex(row, space-between);
gap: @spacing-base;
}
&__head-text {
.flex(column, flex-start);
gap: @spacing-xs;
min-width: 0;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__subtitle {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import AboutPanel from './cpns/AboutPanel.vue'
</script>
<template>
<div class="about-page">
<header class="about-page__head">
<h2 class="about-page__title">
关于我们
</h2>
</header>
<AboutPanel />
</div>
</template>
<style lang="less" scoped>
.about-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__head {
width: 100%;
padding: @spacing-base @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { AppButton, ConfirmDialog, PaginationBar, StatusTag } from '@/components'
import { useProfileStore } from '@/stores/modules/profile'
import type { Address } from '@/types/business/profile'
import AddressDialog from './cpns/AddressDialog.vue'
const profileStore = useProfileStore()
const { addresses, addressQuery, addressTotal } = storeToRefs(profileStore)
const dialogVisible = ref(false)
const editing = ref<Address | null>(null)
const deleteVisible = ref(false)
const deleteTarget = ref<Address | null>(null)
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(address: Address): void {
editing.value = address
dialogVisible.value = true
}
function onSave(payload: Omit<Address, 'id' | 'isDefault'>): void {
dialogVisible.value = false
if (editing.value) {
void profileStore.updateAddress(editing.value.id, payload)
}
else {
void profileStore.createAddress(payload)
}
}
function onDelete(address: Address): void {
deleteTarget.value = address
deleteVisible.value = true
}
function onDeleteConfirm(): void {
if (deleteTarget.value) {
void profileStore.deleteAddress(deleteTarget.value.id)
}
}
function onToggleDefault(address: Address): void {
if (!address.isDefault) {
void profileStore.setDefaultAddress(address.id)
}
}
function onPageChange(page: number): void {
void profileStore.changeAddressPage(page)
}
onMounted(() => {
void profileStore.fetchAddresses()
})
</script>
<template>
<div class="address-page">
<header class="address-page__head">
<h2 class="address-page__title">
收货地址
</h2>
<AppButton type="primary" @click="onCreate">
+新增地址
</AppButton>
</header>
<div class="address-page__grid">
<article
v-for="address in addresses"
:key="address.id"
class="address-card"
>
<div class="address-card__head">
<h3 class="address-card__name">
{{ address.receiverName }}
</h3>
<StatusTag v-if="address.isDefault" label="默认" variant="success" />
</div>
<ul class="address-card__meta text-secondary">
<li>联系方式:{{ address.phone }}</li>
<li>所在地区:{{ address.region }}</li>
<li>详细地址:{{ address.detailAddress }}</li>
</ul>
<div class="address-card__actions">
<button
v-if="!address.isDefault"
class="address-card__btn"
@click="onToggleDefault(address)"
>
设为默认
</button>
<button
v-else
class="address-card__btn address-card__btn--muted"
disabled
>
取消默认
</button>
<button class="address-card__btn" @click="onEdit(address)">
编辑
</button>
<button class="address-card__btn address-card__btn--danger" @click="onDelete(address)">
删除
</button>
</div>
</article>
<p v-if="addresses.length === 0" class="address-page__empty text-secondary">
暂无收货地址,点击右上角新增
</p>
</div>
<PaginationBar
:page="addressQuery.page"
:page-size="addressQuery.pageSize"
:total="addressTotal"
@page-change="onPageChange"
/>
<AddressDialog
v-model:visible="dialogVisible"
:address="editing ?? undefined"
@save="onSave"
/>
<ConfirmDialog
v-model:visible="deleteVisible"
title="删除地址"
message="删除后无法恢复,是否继续?"
confirm-text="删除"
cancel-text="取消"
danger
@confirm="onDeleteConfirm"
/>
</div>
</template>
<style lang="less" scoped>
.address-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__head {
.flex(row, space-between, center);
width: 100%;
padding: @spacing-base @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @spacing-base;
width: 100%;
}
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
.address-card {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, center);
gap: @spacing-sm;
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__meta {
.flex(column, flex-start);
gap: @spacing-xs;
list-style: none;
font-size: @font-size-base;
li {
.ellipsis();
}
}
&__actions {
.flex(row, center);
gap: @spacing-base;
margin-top: auto;
padding-top: @spacing-sm;
border-top: 1px solid @border-color;
width: 100%;
}
&__btn {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover:not(:disabled) {
opacity: 0.75;
}
&--muted {
color: @text-color-disabled;
cursor: not-allowed;
}
&--danger {
color: @danger-color;
}
}
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
const advantages = [
{ title: '全流程线上化', desc: '需求发布、方案对接、进度跟踪、合同结算全程线上完成,状态实时可见。' },
{ title: '优质制造资源', desc: '汇聚软硬件研发与量产优质服务商,严格资质审核,匹配更精准。' },
{ title: '规范化项目管控', desc: '标准化交付节点与验收流程,里程碑透明管控,项目风险早发现早处理。' },
{ title: '一站式服务', desc: '从零部件加工到模具开发、样品试制与批量生产,一个平台完成全部外协。' },
]
</script>
<template>
<div class="about-panel">
<section class="about-panel__section">
<h3 class="about-panel__title">
平台简介
</h3>
<p class="about-panel__text">
虫子智造是一站式研发智造一体化服务平台,聚焦零部件加工、模具开发、样品试制与批量生产需求,打通需求发布、方案对接、项目管控、合同结算全流程,为企业提供高效透明的智能制造外协解决方案。
</p>
</section>
<section class="about-panel__section">
<h3 class="about-panel__title">
我们的使命
</h3>
<p class="about-panel__text">
连接需求方与制造资源,简化外协生产流程,降低企业研发与制造成本,助力中小制造企业数字化转型。
</p>
</section>
<section class="about-panel__section">
<h3 class="about-panel__title">
核心优势
</h3>
<ul class="about-panel__advantages">
<li
v-for="item in advantages"
:key="item.title"
class="about-panel__advantage"
>
<h4 class="about-panel__advantage-title">
{{ item.title }}
</h4>
<p class="about-panel__advantage-desc text-secondary">
{{ item.desc }}
</p>
</li>
</ul>
</section>
<section class="about-panel__section">
<h3 class="about-panel__title">
联系信息
</h3>
<ul class="about-panel__contact">
<li><span>客服热线:</span>400-XXXX-XXXX</li>
<li><span>工作时间:</span>周一至周五 09:00 - 18:00</li>
<li>
<span>平台官网:</span>
<a
href="https://www.chongzizhizao.com"
target="_blank"
rel="noopener noreferrer"
class="about-panel__link"
>
https://www.chongzizhizao.com
</a>
</li>
<li><span>公司地址:</span>山东省日照市</li>
</ul>
</section>
<section class="about-panel__section">
<h3 class="about-panel__title">
版本信息
</h3>
<p class="about-panel__text">
当前系统版本:V1.0.0<br>
更新时间:2026 年 09 月
</p>
</section>
</div>
</template>
<style lang="less" scoped>
.about-panel {
.flex(column, flex-start);
gap: @spacing-base;
&__section {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__text {
font-size: @font-size-base;
color: @text-color;
line-height: 1.8;
}
&__advantages {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @spacing-base;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
&__advantage {
.flex(column, flex-start);
gap: @spacing-xs;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color-light;
}
&__advantage-title {
font-size: @font-size-base;
font-weight: 600;
color: @primary-color;
}
&__advantage-desc {
font-size: @font-size-sm;
line-height: 1.7;
}
&__contact {
.flex(column, flex-start);
gap: @spacing-sm;
margin: 0;
padding: 0;
list-style: none;
font-size: @font-size-base;
color: @text-color;
span {
color: @text-color-secondary;
}
}
&__link {
color: @primary-color;
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton } from '@/components'
import type { Address } from '@/types/business/profile'
interface Props {
address?: Address
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{
save: [payload: Omit<Address, 'id' | 'isDefault'>]
}>()
const form = reactive({
receiverName: '',
phone: '',
region: '',
detailAddress: '',
})
watch(visible, (open) => {
if (!open) return
form.receiverName = props.address?.receiverName ?? ''
form.phone = props.address?.phone ?? ''
form.region = props.address?.region ?? ''
form.detailAddress = props.address?.detailAddress ?? ''
})
function onSave(): void {
if (!form.receiverName.trim()) {
ElMessage.warning('请填写收货人姓名')
return
}
if (!form.phone.trim()) {
ElMessage.warning('请填写联系方式')
return
}
if (!form.region.trim()) {
ElMessage.warning('请填写所在地区')
return
}
if (!form.detailAddress.trim()) {
ElMessage.warning('请填写详细地址')
return
}
emit('save', {
receiverName: form.receiverName.trim(),
phone: form.phone.trim(),
region: form.region.trim(),
detailAddress: form.detailAddress.trim(),
})
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="address-dialog" @click.self="visible = false">
<div class="address-dialog__panel">
<h3 class="address-dialog__title">
{{ props.address ? '编辑地址' : '新增地址' }}
</h3>
<div class="address-dialog__field">
<label class="address-dialog__label">收货人姓名</label>
<input
v-model="form.receiverName"
class="address-dialog__input"
type="text"
placeholder="请输入收货人姓名"
maxlength="20"
>
</div>
<div class="address-dialog__field">
<label class="address-dialog__label">联系方式</label>
<input
v-model="form.phone"
class="address-dialog__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
>
</div>
<div class="address-dialog__field">
<label class="address-dialog__label">所在地区</label>
<input
v-model="form.region"
class="address-dialog__input"
type="text"
placeholder="省/市/区,如:山东省日照市东港区"
maxlength="60"
>
</div>
<div class="address-dialog__field">
<label class="address-dialog__label">详细地址</label>
<textarea
v-model="form.detailAddress"
class="address-dialog__textarea"
placeholder="街道、门牌号、楼层等"
maxlength="120"
rows="3"
/>
</div>
<div class="address-dialog__footer">
<AppButton type="default" @click="visible = false">
取消
</AppButton>
<AppButton type="primary" @click="onSave">
保存
</AppButton>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.address-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 480px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
.flex(column, flex-start);
gap: @spacing-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__field {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__label {
font-size: @font-size-base;
color: @heading-color;
}
&__input,
&__textarea {
width: 100%;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
}
&__input {
height: 40px;
}
&__textarea {
padding: @spacing-sm @spacing-base;
line-height: 1.6;
resize: vertical;
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton, SmsCodeInput } from '@/components'
export type BindContactMode =
| 'phone-bind'
| 'phone-rebind'
| 'email-bind'
| 'email-rebind'
| 'identity'
interface Props {
mode: BindContactMode
hintPhone?: string
hintEmail?: string
}
const props = withDefaults(defineProps<Props>(), {
hintPhone: '',
hintEmail: '',
})
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ submit: [payload: { contact: string; code: string }] }>()
const contact = ref('')
const code = ref('')
const agree = ref(false)
const isEmailMode = computed(() => props.mode === 'email-bind' || props.mode === 'email-rebind')
const isIdentity = computed(() => props.mode === 'identity')
const title = computed<string>(() => {
const map: Record<BindContactMode, string> = {
'phone-bind': '绑定手机号',
'phone-rebind': '验证并换绑',
'email-bind': '绑定邮箱',
'email-rebind': '更换绑定邮箱',
identity: '安全身份验证',
}
return map[props.mode]
})
const fieldLabel = computed<string>(() => {
const map: Record<BindContactMode, string> = {
'phone-bind': '手机号',
'phone-rebind': '新手机号',
'email-bind': '邮箱',
'email-rebind': '新邮箱',
identity: '手机号',
}
return map[props.mode]
})
const submitText = computed<string>(() => {
const map: Record<BindContactMode, string> = {
'phone-bind': '验证并绑定',
'phone-rebind': '验证并换绑',
'email-bind': '确认修改',
'email-rebind': '确认修改',
identity: '验证',
}
return map[props.mode]
})
const contactPlaceholder = computed<string>(() => {
if (isEmailMode.value) return props.hintEmail || '请输入邮箱'
return props.mode === 'phone-rebind' ? '请输入新手机号' : '请输入手机号'
})
const identityDesc = computed(() =>
isIdentity.value
? `安全身份验证:为确保账号安全,请完成身份验证后再进行账号操作,发送验证码至 ${props.hintPhone}`
: '',
)
watch(visible, (open) => {
if (!open) return
contact.value = isIdentity.value ? props.hintPhone : ''
code.value = ''
agree.value = false
})
function onGetCode(): void {
ElMessage.success('验证码已发送')
}
function onSubmit(): void {
if (!contact.value.trim()) {
ElMessage.warning(isEmailMode.value ? '请输入邮箱' : '请输入手机号')
return
}
if (!code.value) {
ElMessage.warning('请输入验证码')
return
}
if (!isIdentity.value && !agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
emit('submit', { contact: contact.value.trim(), code: code.value })
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="bind-dialog" @click.self="visible = false">
<div class="bind-dialog__panel">
<h3 class="bind-dialog__title">
{{ title }}
</h3>
<p v-if="identityDesc" class="bind-dialog__desc text-secondary">
{{ identityDesc }}
</p>
<div class="bind-dialog__field">
<label class="bind-dialog__label">{{ fieldLabel }}</label>
<input
v-model="contact"
class="bind-dialog__input"
type="text"
:placeholder="contactPlaceholder"
>
</div>
<div class="bind-dialog__field">
<label class="bind-dialog__label">验证码</label>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
</div>
<label v-if="!isIdentity" class="bind-dialog__agree">
<input v-model="agree" type="checkbox">
<span>我已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<div class="bind-dialog__footer">
<AppButton type="default" @click="visible = false">
取消
</AppButton>
<AppButton type="primary" @click="onSubmit">
{{ submitText }}
</AppButton>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.bind-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 440px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
.flex(column, flex-start);
gap: @spacing-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-sm;
line-height: 1.6;
}
&__field {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__label {
font-size: @font-size-base;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
}
&__agree {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-sm;
color: @text-color;
cursor: pointer;
input {
accent-color: @primary-color;
}
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton, PasswordStrength, SmsCodeInput } from '@/components'
interface Props {
/** 绑定手机号(脱敏展示) */
phone: string
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{
submit: [payload: { code: string; password: string; confirm: string }]
}>()
const code = ref('')
const password = ref('')
const confirm = ref('')
const agree = ref(false)
watch(visible, (open) => {
if (!open) return
code.value = ''
password.value = ''
confirm.value = ''
agree.value = false
})
function onGetCode(): void {
ElMessage.success('验证码已发送')
}
function onSubmit(): void {
if (!code.value) {
ElMessage.warning('请输入验证码')
return
}
if (!password.value) {
ElMessage.warning('请输入新密码')
return
}
if (!agree.value) {
ElMessage.warning('请先阅读并同意《用户协议》和《隐私政策》')
return
}
emit('submit', {
code: code.value,
password: password.value,
confirm: confirm.value,
})
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="change-pwd-dialog" @click.self="visible = false">
<div class="change-pwd-dialog__panel">
<h3 class="change-pwd-dialog__title">
修改密码
</h3>
<div class="change-pwd-dialog__field">
<label class="change-pwd-dialog__label">手机号</label>
<input
class="change-pwd-dialog__input"
type="text"
:value="props.phone"
readonly
>
</div>
<div class="change-pwd-dialog__field">
<label class="change-pwd-dialog__label">验证码</label>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
</div>
<div class="change-pwd-dialog__field">
<label class="change-pwd-dialog__label">设置密码</label>
<input
v-model="password"
class="change-pwd-dialog__input"
type="password"
placeholder="请输入密码(6-20位)"
maxlength="20"
>
<PasswordStrength :password="password" />
</div>
<div class="change-pwd-dialog__field">
<label class="change-pwd-dialog__label">确认密码</label>
<input
v-model="confirm"
class="change-pwd-dialog__input"
type="password"
placeholder="请再次输入密码"
maxlength="20"
@keyup.enter="onSubmit"
>
</div>
<label class="change-pwd-dialog__agree">
<input v-model="agree" type="checkbox">
<span>我已阅读并同意《用户协议》和《隐私政策》</span>
</label>
<div class="change-pwd-dialog__footer">
<AppButton type="default" @click="visible = false">
取消
</AppButton>
<AppButton type="primary" @click="onSubmit">
确认修改
</AppButton>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.change-pwd-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 440px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
.flex(column, flex-start);
gap: @spacing-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__field {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__label {
font-size: @font-size-base;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&:read-only {
color: @text-color-secondary;
cursor: not-allowed;
}
}
&__agree {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-sm;
color: @text-color;
cursor: pointer;
input {
accent-color: @primary-color;
}
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CaseCard } from '@/components'
import type { Case } from '@/types/business/cases'
interface Props {
cases: Case[]
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
</script>
<template>
<div class="favorites-grid">
<template v-if="cases.length > 0">
<CaseCard
v-for="item in cases"
:key="item.id"
:item="item"
@view="emit('view', $event)"
/>
</template>
<p v-else class="favorites-grid__empty text-secondary">
暂无收藏案例,快去案例库挑选感兴趣的内容吧
</p>
</div>
</template>
<style lang="less" scoped>
.favorites-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { AppButton, FileList, UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import type { Feedback } from '@/types/business/profile'
interface Props {
/** 递增时清空表单(父级提交成功/重置信号) */
resetSignal?: number
}
const props = withDefaults(defineProps<Props>(), { resetSignal: 0 })
const emit = defineEmits<{
submit: [feedback: Feedback]
reset: []
preview: [file: FileInfo]
}>()
const form = reactive({
title: '',
description: '',
})
const attachments = reactive<FileInfo[]>([])
function clear(): void {
form.title = ''
form.description = ''
attachments.splice(0, attachments.length)
}
watch(
() => props.resetSignal,
() => clear(),
)
function onUpload(file: File): void {
attachments.push({
name: file.name,
size: file.size,
uploadTime: new Date().toISOString().slice(0, 10),
url: '',
})
}
function onDeleteFile(file: FileInfo): void {
const index = attachments.indexOf(file)
if (index >= 0) attachments.splice(index, 1)
}
function onSubmit(): void {
emit('submit', {
title: form.title,
description: form.description,
attachments: [...attachments],
})
}
function onReset(): void {
emit('reset')
}
</script>
<template>
<div class="feedback-form">
<div class="feedback-form__field">
<label class="feedback-form__label">反馈标题<span class="feedback-form__required">*</span></label>
<input
v-model="form.title"
class="feedback-form__input"
type="text"
placeholder="请简要描述您的反馈标题"
maxlength="50"
>
</div>
<div class="feedback-form__field">
<label class="feedback-form__label">详细描述<span class="feedback-form__required">*</span></label>
<textarea
v-model="form.description"
class="feedback-form__textarea"
placeholder="请详细描述您遇到的问题或建议,便于我们更快为您处理"
maxlength="500"
rows="6"
/>
<span class="feedback-form__counter text-secondary">{{ form.description.length }}/500</span>
</div>
<p class="feedback-form__tip">
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
<div class="feedback-form__field">
<label class="feedback-form__label">附件</label>
<UploadArea @upload="onUpload" />
<FileList
:files="attachments"
@preview="emit('preview', $event)"
@delete="onDeleteFile"
/>
</div>
<div class="feedback-form__footer">
<AppButton type="primary" @click="onSubmit">
提交反馈
</AppButton>
<AppButton type="default" @click="onReset">
重置
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.feedback-form {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__field {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__label {
font-size: @font-size-base;
color: @heading-color;
}
&__required {
margin-left: 2px;
color: @danger-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
}
&__textarea {
width: 100%;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
line-height: 1.6;
outline: none;
transition: border-color 0.2s;
resize: vertical;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
}
&__counter {
align-self: flex-end;
font-size: @font-size-sm;
}
&__tip {
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
line-height: 1.6;
}
&__footer {
.flex(row, flex-start);
gap: @spacing-sm;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { HelpFaqGroup } from '@/types/business/profile'
interface Props {
groups: HelpFaqGroup[]
}
defineProps<Props>()
</script>
<template>
<div class="help-faq">
<details
v-for="group in groups"
:key="group.title"
class="help-faq__group"
open
>
<summary class="help-faq__summary">
{{ group.title }}
</summary>
<ul class="help-faq__list">
<li
v-for="item in group.items"
:key="item.q"
class="help-faq__item"
>
<p class="help-faq__q">
Q:{{ item.q }}
</p>
<p class="help-faq__a text-secondary">
A:{{ item.a }}
</p>
</li>
</ul>
</details>
<p v-if="groups.length === 0" class="help-faq__empty text-secondary">
暂无常见问题
</p>
</div>
</template>
<style lang="less" scoped>
.help-faq {
.flex(column, flex-start);
gap: @spacing-base;
&__group {
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow: hidden;
}
&__summary {
.flex(row, flex-start, center);
padding: @spacing-base @spacing-xl;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
cursor: pointer;
list-style: none;
transition: background 0.2s;
user-select: none;
&::-webkit-details-marker {
display: none;
}
&::after {
content: '▾';
margin-left: auto;
font-size: @font-size-base;
color: @text-color-secondary;
transition: transform 0.2s;
}
&:hover {
background: @bg-color-light;
}
}
&__group[open] > .help-faq__summary::after {
transform: rotate(180deg);
}
&__list {
.flex(column, flex-start);
gap: @spacing-base;
margin: 0;
padding: 0 @spacing-xl @spacing-lg;
list-style: none;
border-top: 1px solid @border-color;
}
&__item {
.flex(column, flex-start);
gap: @spacing-xs;
padding-top: @spacing-base;
}
&__q {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__a {
font-size: @font-size-base;
line-height: 1.7;
}
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
// 热线展示(静态 mock,接入站点配置后由 store 提供)
const HOTLINE = '000-000-0000'
const WORK_TIME = '工作日 8:00-12:00 14:00-18:00'
</script>
<template>
<div class="hotline-panel">
<div class="hotline-panel__card">
<span class="hotline-panel__icon" aria-hidden="true">📞</span>
<h3 class="hotline-panel__title">
专属客服热线
</h3>
<p class="hotline-panel__number">
{{ HOTLINE }}
</p>
<p class="hotline-panel__time text-secondary">
{{ WORK_TIME }}
</p>
</div>
</div>
</template>
<style lang="less" scoped>
.hotline-panel {
.flex(row, center);
padding: @spacing-xxl 0;
&__card {
.flex(column, center);
gap: @spacing-base;
width: 480px;
max-width: 100%;
padding: @spacing-xxl @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
text-align: center;
}
&__icon {
font-size: @font-size-xxl * 2;
line-height: 1;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__number {
font-size: @font-size-xxl + @spacing-sm;
font-weight: 700;
color: @primary-color;
line-height: 1.2;
}
&__time {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
interface MenuItem {
to: string
icon: string
label: string
}
const menus: MenuItem[] = [
{ to: '/profile/security', icon: '🔒', label: '账号安全' },
{ to: '/profile/address', icon: '📍', label: '收货地址' },
{ to: '/profile/favorites', icon: '⭐', label: '我的收藏' },
{ to: '/profile/qualifications', icon: '📜', label: '资质管理' },
{ to: '/profile/feedback', icon: '💡', label: '意见反馈' },
{ to: '/profile/help', icon: '❓', label: '帮助中心' },
{ to: '/profile/about', icon: 'ℹ️', label: '关于我们' },
{ to: '/profile/hotline', icon: '📞', label: '客服热线' },
]
</script>
<template>
<nav class="profile-sider" aria-label="个人中心导航">
<ul class="profile-sider__menu">
<li v-for="item in menus" :key="item.to">
<RouterLink :to="item.to" class="profile-sider__item">
<span class="profile-sider__icon">{{ item.icon }}</span>
<span class="profile-sider__label">{{ item.label }}</span>
</RouterLink>
</li>
</ul>
<div class="profile-sider__footer">
<RouterLink
to="/profile/hotline"
class="profile-sider__hotline"
title="专属客服热线"
>
📞 专属客服热线
</RouterLink>
</div>
</nav>
</template>
<style lang="less" scoped>
.profile-sider {
.flex(column, space-between);
min-height: 480px;
padding: @spacing-sm 0;
&__menu {
.flex(column, flex-start);
gap: 2px;
list-style: none;
}
&__item {
.flex(row, flex-start);
gap: @spacing-sm;
height: 44px;
padding: 0 @spacing-base;
border-radius: @radius-base;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
background: @primary-light;
color: @primary-color;
}
&.router-link-active {
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
&__icon {
font-size: @font-size-lg;
line-height: 1;
}
&__footer {
padding: @spacing-base;
border-top: 1px solid @border-color;
text-align: center;
}
&__hotline {
font-size: @font-size-sm;
color: @text-color-secondary;
transition: color 0.2s;
&:hover {
color: @primary-color;
}
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Profile } from '@/types/business/profile'
interface Props {
profile: Profile | null
}
const props = defineProps<Props>()
const emit = defineEmits<{ saveNickname: [name: string] }>()
const draft = ref('')
watch(
() => props.profile?.nickname,
(name) => {
draft.value = name ?? ''
},
{ immediate: true },
)
function save(): void {
const name = draft.value.trim()
if (!name || name === props.profile?.nickname) return
emit('saveNickname', name)
}
</script>
<template>
<div class="profile-user-card">
<div class="profile-user-card__avatar" aria-hidden="true">
王
</div>
<div class="profile-user-card__info">
<input
v-model="draft"
class="profile-user-card__name"
type="text"
placeholder="请输入名称"
maxlength="20"
@keyup.enter="save"
@blur="save"
>
<p class="profile-user-card__phone text-secondary">
{{ profile?.phone ?? '' }}
</p>
</div>
</div>
</template>
<style lang="less" scoped>
.profile-user-card {
.flex(row, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__avatar {
.flex(row, center);
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: @radius-round;
background: linear-gradient(135deg, @primary-color, @primary-hover);
color: @white;
font-size: @font-size-xxl;
font-weight: 600;
}
&__info {
.flex(column, flex-start);
gap: @spacing-xs;
min-width: 0;
}
&__name {
width: 240px;
height: 34px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-base;
background: transparent;
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
outline: none;
transition: all 0.2s;
&:hover {
border-color: @border-color;
}
&:focus {
border-color: @primary-color;
background: @bg-color-light;
}
&::placeholder {
font-size: @font-size-lg;
font-weight: 400;
color: @text-color-disabled;
}
}
&__phone {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { Qualification, QualificationStatus } from '@/types/business/profile'
interface Props {
qualification: Qualification
}
defineProps<Props>()
const emit = defineEmits<{
edit: [qualification: Qualification]
delete: [qualification: Qualification]
reupload: [qualification: Qualification]
}>()
const STATUS_VARIANTS: Record<QualificationStatus, 'success' | 'warning' | 'danger'> = {
已认证: 'success',
审核中: 'warning',
未通过: 'danger',
}
</script>
<template>
<article class="qual-card">
<div class="qual-card__head">
<h3 class="qual-card__name">
{{ qualification.certName }}
</h3>
<StatusTag
:label="qualification.status"
:variant="STATUS_VARIANTS[qualification.status]"
/>
</div>
<ul class="qual-card__meta">
<li><span>资质类型:</span>{{ qualification.type }}</li>
<li><span>证书编号:</span>{{ qualification.certNo }}</li>
<li><span>发证单位:</span>{{ qualification.issuer }}</li>
<li><span>有效期:</span>{{ qualification.validFrom }} 至 {{ qualification.validTo }}</li>
</ul>
<div class="qual-card__actions">
<button class="qual-card__btn" @click="emit('edit', qualification)">
编辑
</button>
<button class="qual-card__btn qual-card__btn--danger" @click="emit('delete', qualification)">
删除
</button>
<button
v-if="qualification.status === '未通过'"
class="qual-card__btn qual-card__btn--warning"
@click="emit('reupload', qualification)"
>
重新上传
</button>
</div>
</article>
</template>
<style lang="less" scoped>
.qual-card {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, flex-start);
gap: @spacing-sm;
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.multi-ellipsis(2);
}
&__meta {
.flex(column, flex-start);
gap: @spacing-xs;
list-style: none;
font-size: @font-size-base;
color: @text-color;
li {
.ellipsis();
}
span {
color: @text-color-secondary;
}
}
&__actions {
.flex(row, center);
gap: @spacing-base;
margin-top: auto;
padding-top: @spacing-sm;
border-top: 1px solid @border-color;
}
&__btn {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--danger {
color: @danger-color;
}
&--warning {
color: @warning-color;
}
}
}
</style>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton, FileList, UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import type { Qualification, QualificationInput, QualificationType } from '@/types/business/profile'
interface Props {
qualification?: Qualification
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ save: [payload: QualificationInput] }>()
const TYPE_OPTIONS: QualificationType[] = [
'营业执照',
'生产许可证',
'体系认证',
'检测报告',
'授权书',
'其他资质',
]
const form = reactive({
type: '营业执照' as QualificationType,
certName: '',
certNo: '',
issuer: '',
validFrom: '',
validTo: '',
})
const attachments = reactive<FileInfo[]>([])
watch(visible, (open) => {
if (!open) return
form.type = props.qualification?.type ?? '营业执照'
form.certName = props.qualification?.certName ?? ''
form.certNo = props.qualification?.certNo ?? ''
form.issuer = props.qualification?.issuer ?? ''
form.validFrom = props.qualification?.validFrom ?? ''
form.validTo = props.qualification?.validTo ?? ''
attachments.splice(0, attachments.length, ...(props.qualification?.attachments ?? []))
})
function onUpload(file: File): void {
attachments.push({
name: file.name,
size: file.size,
uploadTime: new Date().toISOString().slice(0, 10),
url: '',
})
}
function onPreview(file: FileInfo): void {
if (file.url) {
window.open(file.url, '_blank')
return
}
ElMessage.info('附件预览待接口接入')
}
function onDeleteFile(file: FileInfo): void {
const index = attachments.indexOf(file)
if (index >= 0) attachments.splice(index, 1)
}
function onSave(): void {
if (!form.certName.trim()) {
ElMessage.warning('请填写证书名称')
return
}
if (!form.certNo.trim()) {
ElMessage.warning('请填写证书编号')
return
}
if (!form.issuer.trim()) {
ElMessage.warning('请填写发证单位')
return
}
if (!form.validFrom || !form.validTo) {
ElMessage.warning('请选择有效期起止时间')
return
}
emit('save', {
id: props.qualification?.id,
type: form.type,
certName: form.certName.trim(),
certNo: form.certNo.trim(),
issuer: form.issuer.trim(),
validFrom: form.validFrom,
validTo: form.validTo,
attachments: [...attachments],
})
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="qual-dialog" @click.self="visible = false">
<div class="qual-dialog__panel">
<h3 class="qual-dialog__title">
{{ props.qualification ? '编辑资质' : '新增资质' }}
</h3>
<div class="qual-dialog__field">
<label class="qual-dialog__label">资质类型</label>
<select v-model="form.type" class="qual-dialog__input">
<option v-for="item in TYPE_OPTIONS" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
<div class="qual-dialog__field">
<label class="qual-dialog__label">证书名称</label>
<input
v-model="form.certName"
class="qual-dialog__input"
type="text"
placeholder="请输入证书名称"
maxlength="60"
>
</div>
<div class="qual-dialog__field">
<label class="qual-dialog__label">证书编号</label>
<input
v-model="form.certNo"
class="qual-dialog__input"
type="text"
placeholder="请输入证书编号"
maxlength="40"
>
</div>
<div class="qual-dialog__field">
<label class="qual-dialog__label">发证单位</label>
<input
v-model="form.issuer"
class="qual-dialog__input"
type="text"
placeholder="请输入发证单位"
maxlength="60"
>
</div>
<div class="qual-dialog__field">
<label class="qual-dialog__label">有效期</label>
<div class="qual-dialog__range">
<input v-model="form.validFrom" class="qual-dialog__input" type="date">
<span class="qual-dialog__sep text-secondary">至</span>
<input v-model="form.validTo" class="qual-dialog__input" type="date">
</div>
<p class="qual-dialog__hint text-secondary">
起始时间:{{ form.validFrom || '未选择' }} · 截止时间:{{ form.validTo || '未选择' }}
</p>
</div>
<div class="qual-dialog__field">
<label class="qual-dialog__label">附件</label>
<UploadArea @upload="onUpload" />
<FileList
:files="attachments"
@preview="onPreview"
@delete="onDeleteFile"
/>
</div>
<div class="qual-dialog__footer">
<AppButton type="default" @click="visible = false">
取消
</AppButton>
<AppButton type="primary" @click="onSave">
保存
</AppButton>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.qual-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
overflow-y: auto;
&__panel {
width: 520px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
.flex(column, flex-start);
gap: @spacing-base;
.scrollbar();
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__field {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
}
&__label {
font-size: @font-size-base;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @text-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
}
&__range {
.flex(row, center);
gap: @spacing-sm;
}
&__sep {
flex-shrink: 0;
font-size: @font-size-sm;
}
&__hint {
font-size: @font-size-sm;
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AppButton } from '@/components'
import type { Profile } from '@/types/business/profile'
interface Props {
profile: Profile | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
changePassword: []
bindPhone: []
bindEmail: [mode: 'email-bind' | 'email-rebind']
unbindEmail: []
deleteAccount: []
}>()
const agree = ref(false)
const hasEmail = computed(() => Boolean(props.profile?.email))
/** 注销条件(mock 全部满足) */
const conditions = [
'账号无未完成的订单',
'无未结清的款项',
'无进行中的合同',
'无未处理的工单',
]
/** 注销风险提示 */
const risks = [
'账号注销后将无法登录本应用,所有个人信息将被永久删除',
'您名下的订单、合同、工单等业务数据将一并清除,无法找回',
'注销后同一手机号/邮箱将无法再次注册本应用',
'如您有未完成的订单或服务请先处理完毕后再进行注销',
]
</script>
<template>
<div class="security-panel">
<!-- 修改密码 -->
<section class="security-panel__row">
<div class="security-panel__main">
<h3 class="security-panel__title">
修改密码
</h3>
<p class="security-panel__status">
<span class="security-panel__badge security-panel__badge--ok">已设置</span>
<span class="security-panel__note text-secondary">上次修改:{{ profile?.passwordModifiedAt }}</span>
</p>
</div>
<AppButton type="default" @click="emit('changePassword')">
修改密码
</AppButton>
</section>
<!-- 绑定手机 -->
<section class="security-panel__row">
<div class="security-panel__main">
<h3 class="security-panel__title">
绑定手机
</h3>
<p class="security-panel__status">
<span class="security-panel__badge security-panel__badge--ok">已绑定 {{ profile?.phone }}</span>
</p>
<p class="security-panel__note text-secondary">
注:换绑新手机号之后,可以用新的手机号及当前密码登录
</p>
</div>
<AppButton type="default" @click="emit('bindPhone')">
绑定手机
</AppButton>
</section>
<!-- 绑定邮箱 -->
<section class="security-panel__row">
<div class="security-panel__main">
<h3 class="security-panel__title">
绑定邮箱
</h3>
<p class="security-panel__status">
<template v-if="hasEmail">
<span class="security-panel__badge security-panel__badge--ok">已绑定 {{ profile?.email }}</span>
</template>
<template v-else>
<span class="security-panel__badge">未绑定</span>
</template>
</p>
</div>
<div class="security-panel__actions">
<AppButton
v-if="!hasEmail"
type="default"
@click="emit('bindEmail', 'email-bind')"
>
绑定邮箱
</AppButton>
<template v-else>
<AppButton type="default" @click="emit('bindEmail', 'email-rebind')">
更换绑定邮箱
</AppButton>
<AppButton type="text" @click="emit('unbindEmail')">
取消绑定
</AppButton>
</template>
</div>
</section>
<!-- 注销账号(danger 区) -->
<section class="security-panel__danger">
<h3 class="security-panel__danger-title">
注销账号
</h3>
<div class="security-panel__block">
<h4 class="security-panel__block-title">
注销条件
</h4>
<ul class="security-panel__checks">
<li v-for="item in conditions" :key="item" class="security-panel__check">
<span class="security-panel__check-ok">✓</span>{{ item }}
</li>
</ul>
</div>
<div class="security-panel__block">
<h4 class="security-panel__block-title">
注销风险提示
</h4>
<ol class="security-panel__risks">
<li v-for="(item, index) in risks" :key="item">
{{ index + 1 }}. {{ item }}
</li>
</ol>
</div>
<label class="security-panel__agree">
<input v-model="agree" type="checkbox">
<span>已阅读并同意《账号注销协议》</span>
</label>
<AppButton
type="default"
class="security-panel__delete-btn"
:disabled="!agree"
@click="emit('deleteAccount')"
>
注销账号
</AppButton>
</section>
</div>
</template>
<style lang="less" scoped>
.security-panel {
.flex(column, flex-start);
gap: @spacing-base;
&__row {
.flex(row, space-between, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__main {
min-width: 0;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__status {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
margin-top: @spacing-sm;
font-size: @font-size-base;
}
&__badge {
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color-secondary;
font-size: @font-size-sm;
&--ok {
background: fade(@success-color, 12%);
color: @success-color;
}
}
&__note {
margin-top: @spacing-xs;
font-size: @font-size-sm;
line-height: 1.6;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__danger {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border: 1px solid fade(@danger-color, 35%);
border-radius: @radius-lg;
background: @white;
}
&__danger-title {
font-size: @font-size-lg;
font-weight: 600;
color: @danger-color;
}
&__block {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__block-title {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__checks {
.flex(column, flex-start);
gap: @spacing-xs;
list-style: none;
}
&__check {
font-size: @font-size-base;
color: @text-color;
}
&__check-ok {
margin-right: @spacing-xs;
font-weight: 600;
color: @success-color;
}
&__risks {
margin: 0;
padding-left: @spacing-lg;
.flex(column, flex-start);
gap: @spacing-xs;
font-size: @font-size-base;
color: @text-color;
line-height: 1.6;
}
&__agree {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
cursor: pointer;
input {
accent-color: @danger-color;
}
}
// AppButton 根元素可被父级 scoped 命中:danger 描边按钮
&__delete-btn {
border-color: @danger-color !important;
color: @danger-color !important;
&:hover:not(:disabled) {
background: fade(@danger-color, 8%) !important;
border-color: @danger-color !important;
color: @danger-color !important;
}
}
}
</style>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { FilterPanel, PaginationBar } from '@/components'
import type { BusinessType } from '@/types/api'
import { useProfileStore } from '@/stores/modules/profile'
import FavoritesGrid from './cpns/FavoritesGrid.vue'
interface CategoryOption {
key: BusinessType | 'all'
label: string
}
const CATEGORY_OPTIONS: CategoryOption[] = [
{ key: 'all', label: '全部' },
{ key: 'internet', label: '互联网软件' },
{ key: 'hardware', label: '硬件电路' },
{ key: 'iot', label: '物联网全案' },
{ key: 'manufacturing', label: '生产制造' },
]
const router = useRouter()
const profileStore = useProfileStore()
const { favorites, favoritesTotal, favoritesCounts, favoritesQuery } = storeToRefs(profileStore)
// 本地搜索条件(搜索/重置时同步进 store)
const keyword = ref('')
const category = ref<BusinessType | 'all'>('all')
function countOf(key: BusinessType | 'all'): number {
if (key === 'all') return favoritesTotal.value
return favoritesCounts.value[key] ?? 0
}
function onSearch(): void {
void profileStore.searchFavorites({ keyword: keyword.value, category: category.value })
}
function onReset(): void {
keyword.value = ''
category.value = 'all'
void profileStore.resetFavorites()
}
function onSelectCategory(key: BusinessType | 'all'): void {
category.value = key
void profileStore.searchFavorites({ keyword: keyword.value, category: key })
}
function onPageChange(page: number): void {
void profileStore.changeFavoritesPage(page)
}
function onViewDetail(id: string): void {
void router.push(`/cases/${id}`)
}
onMounted(() => {
keyword.value = favoritesQuery.value.keyword
category.value = favoritesQuery.value.category
void Promise.all([profileStore.fetchFavorites(), profileStore.fetchFavoritesCounts()])
})
</script>
<template>
<div class="favorites-page">
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索收藏案例标题"
@search="onSearch"
@reset="onReset"
/>
<div class="favorites-page__categories">
<button
v-for="item in CATEGORY_OPTIONS"
:key="item.key"
class="favorites-page__cat"
:class="{ 'is-active': category === item.key }"
@click="onSelectCategory(item.key)"
>
{{ item.label }}({{ countOf(item.key) }})
</button>
</div>
<FavoritesGrid :cases="favorites" @view="onViewDetail" />
<PaginationBar
:page="favoritesQuery.page"
:page-size="favoritesQuery.pageSize"
:total="favoritesTotal"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.favorites-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__categories {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-sm;
width: 100%;
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__cat {
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FileInfo } from '@/types/api'
import type { Feedback } from '@/types/business/profile'
import { useProfileStore } from '@/stores/modules/profile'
import FeedbackForm from './cpns/FeedbackForm.vue'
const profileStore = useProfileStore()
const resetSignal = ref(0)
function onSubmit(feedback: Feedback): void {
if (!feedback.title.trim()) {
ElMessage.warning('请填写反馈标题')
return
}
if (!feedback.description.trim()) {
ElMessage.warning('请填写详细描述')
return
}
void profileStore.submitFeedback(feedback).then(() => {
ElMessage.success('提交成功')
resetSignal.value += 1
})
}
function onReset(): void {
resetSignal.value += 1
}
function onPreview(file: FileInfo): void {
if (file.url) {
window.open(file.url, '_blank')
return
}
ElMessage.info('附件预览待接口接入')
}
</script>
<template>
<div class="feedback-page">
<header class="feedback-page__head">
<h2 class="feedback-page__title">
意见反馈
</h2>
<p class="feedback-page__sub text-secondary">
您的每一条建议都是我们前进的动力
</p>
</header>
<FeedbackForm
:reset-signal="resetSignal"
@submit="onSubmit"
@reset="onReset"
@preview="onPreview"
/>
</div>
</template>
<style lang="less" scoped>
.feedback-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__head {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
padding: @spacing-base @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__sub {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProfileStore } from '@/stores/modules/profile'
import HelpFaq from './cpns/HelpFaq.vue'
const profileStore = useProfileStore()
const { helpFaqs } = storeToRefs(profileStore)
onMounted(() => {
void profileStore.fetchHelpFaqs()
})
</script>
<template>
<div class="help-page">
<header class="help-page__head">
<h2 class="help-page__title">
帮助中心
</h2>
<p class="help-page__sub text-secondary">
常见问题与使用指南,帮您快速上手平台
</p>
</header>
<HelpFaq :groups="helpFaqs" />
</div>
</template>
<style lang="less" scoped>
.help-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__head {
.flex(column, flex-start);
gap: @spacing-xs;
width: 100%;
padding: @spacing-base @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__sub {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import HotlinePanel from './cpns/HotlinePanel.vue'
</script>
<template>
<div class="hotline-page">
<HotlinePanel />
</div>
</template>
<style lang="less" scoped>
.hotline-page {
width: 100%;
}
</style>

View File

@@ -0,0 +1,14 @@
export { default } from './index.vue'
export { default as ProfileSider } from './cpns/ProfileSider.vue'
export { default as ProfileUserCard } from './cpns/ProfileUserCard.vue'
export { default as SecurityPanel } from './cpns/SecurityPanel.vue'
export { default as ChangePasswordDialog } from './cpns/ChangePasswordDialog.vue'
export { default as BindContactDialog } from './cpns/BindContactDialog.vue'
export { default as AddressDialog } from './cpns/AddressDialog.vue'
export { default as FavoritesGrid } from './cpns/FavoritesGrid.vue'
export { default as QualificationCard } from './cpns/QualificationCard.vue'
export { default as QualificationDialog } from './cpns/QualificationDialog.vue'
export { default as FeedbackForm } from './cpns/FeedbackForm.vue'
export { default as HelpFaq } from './cpns/HelpFaq.vue'
export { default as AboutPanel } from './cpns/AboutPanel.vue'
export { default as HotlinePanel } from './cpns/HotlinePanel.vue'

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProfileStore } from '@/stores/modules/profile'
import ProfileSider from './cpns/ProfileSider.vue'
import ProfileUserCard from './cpns/ProfileUserCard.vue'
// 布局容器:顶部用户卡 + 左侧菜单 / 右侧子路由出口,数据只经 store
const profileStore = useProfileStore()
const { profile } = storeToRefs(profileStore)
function onSaveNickname(name: string): void {
void profileStore.saveNickname(name)
}
onMounted(() => {
void profileStore.fetchProfile()
})
</script>
<template>
<div class="profile">
<div class="profile__container">
<ProfileUserCard :profile="profile" @save-nickname="onSaveNickname" />
<div class="profile__body">
<aside class="profile__aside">
<ProfileSider />
</aside>
<main class="profile__main">
<RouterView />
</main>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.profile {
padding-bottom: @spacing-xxl;
background: @bg-color;
&__container {
width: @container-width;
max-width: calc(100% - @spacing-xl);
margin: 0 auto;
padding-top: @spacing-xl;
}
&__body {
.flex(row, flex-start);
gap: @spacing-base;
margin-top: @spacing-base;
align-items: flex-start;
}
&__aside {
width: 220px;
flex-shrink: 0;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow: hidden;
}
&__main {
flex: 1;
min-width: 0;
.flex(column, flex-start);
gap: @spacing-base;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { AppButton, ConfirmDialog, PaginationBar } from '@/components'
import { useProfileStore } from '@/stores/modules/profile'
import type { Qualification, QualificationInput } from '@/types/business/profile'
import QualificationCard from './cpns/QualificationCard.vue'
import QualificationDialog from './cpns/QualificationDialog.vue'
const profileStore = useProfileStore()
const { qualifications, qualificationQuery, qualificationTotal } = storeToRefs(profileStore)
const dialogVisible = ref(false)
const editing = ref<Qualification | null>(null)
const deleteVisible = ref(false)
const deleteTarget = ref<Qualification | null>(null)
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(item: Qualification): void {
editing.value = item
dialogVisible.value = true
}
function onSave(payload: QualificationInput): void {
dialogVisible.value = false
if (payload.id) {
void profileStore.updateQualification(payload.id, payload)
}
else {
void profileStore.createQualification(payload)
}
}
function onDelete(item: Qualification): void {
deleteTarget.value = item
deleteVisible.value = true
}
function onDeleteConfirm(): void {
if (deleteTarget.value) {
void profileStore.deleteQualification(deleteTarget.value.id)
}
}
function onReupload(): void {
ElMessage.info('请在编辑中重新上传附件')
}
function onPageChange(page: number): void {
void profileStore.changeQualificationPage(page)
}
onMounted(() => {
void profileStore.fetchQualifications()
})
</script>
<template>
<div class="qual-page">
<header class="qual-page__head">
<h2 class="qual-page__title">
资质管理
</h2>
<AppButton type="primary" @click="onCreate">
+新增资质
</AppButton>
</header>
<div class="qual-page__grid">
<QualificationCard
v-for="item in qualifications"
:key="item.id"
:qualification="item"
@edit="onEdit"
@delete="onDelete"
@reupload="onReupload"
/>
<p v-if="qualifications.length === 0" class="qual-page__empty text-secondary">
暂无资质,点击右上角新增
</p>
</div>
<PaginationBar
:page="qualificationQuery.page"
:page-size="qualificationQuery.pageSize"
:total="qualificationTotal"
@page-change="onPageChange"
/>
<QualificationDialog
v-model:visible="dialogVisible"
:qualification="editing ?? undefined"
@save="onSave"
/>
<ConfirmDialog
v-model:visible="deleteVisible"
title="删除资质"
message="删除后无法恢复,是否继续?"
confirm-text="删除"
cancel-text="取消"
danger
@confirm="onDeleteConfirm"
/>
</div>
</template>
<style lang="less" scoped>
.qual-page {
.flex(column, flex-start);
gap: @spacing-base;
width: 100%;
&__head {
.flex(row, space-between, center);
width: 100%;
padding: @spacing-base @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @spacing-base;
width: 100%;
}
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ConfirmDialog } from '@/components'
import { getPasswordLevel, isValidPassword } from '@/utils/validate'
import { useProfileStore } from '@/stores/modules/profile'
import SecurityPanel from './cpns/SecurityPanel.vue'
import ChangePasswordDialog from './cpns/ChangePasswordDialog.vue'
import BindContactDialog from './cpns/BindContactDialog.vue'
import type { BindContactMode } from './cpns/BindContactDialog.vue'
const router = useRouter()
const profileStore = useProfileStore()
const { profile } = storeToRefs(profileStore)
// ── 修改密码 ──
const pwdVisible = ref(false)
function onChangePasswordSubmit(payload: { code: string; password: string; confirm: string }): void {
if (payload.password !== payload.confirm) {
ElMessage.warning('两次密码不一致')
return
}
if (!isValidPassword(payload.password) || getPasswordLevel(payload.password) === 'weak') {
ElMessage.warning('密码等级过低,请重新输入')
return
}
void profileStore
.changePassword({ code: payload.code, password: payload.password })
.then(() => {
pwdVisible.value = false
})
}
// ── 绑定/换绑(手机 & 邮箱) ──
const bindVisible = ref(false)
const bindMode = ref<BindContactMode>('phone-rebind')
function onBindPhone(): void {
bindMode.value = 'phone-rebind'
bindVisible.value = true
}
function onBindEmail(mode: 'email-bind' | 'email-rebind'): void {
bindMode.value = mode
bindVisible.value = true
}
function onBindSubmit(payload: { contact: string; code: string }): void {
const action
= bindMode.value === 'phone-rebind' || bindMode.value === 'phone-bind'
? profileStore.bindPhone(payload.contact, payload.code)
: profileStore.bindEmail(payload.contact, payload.code)
void action.then(() => {
bindVisible.value = false
})
}
// ── 取消绑定邮箱 ──
const unbindVisible = ref(false)
function onUnbindConfirm(): void {
void profileStore.unbindEmail()
}
// ── 注销账号 ──
const deleteVisible = ref(false)
const identityVisible = ref(false)
function onDeleteConfirm(): void {
// 确认注销 → 进入安全身份验证
identityVisible.value = true
}
function onIdentitySubmit(payload: { contact: string; code: string }): void {
void (async () => {
await profileStore.verifyIdentity(payload.contact, payload.code)
await profileStore.deleteAccount()
setTimeout(() => {
void router.push('/login')
}, 3000)
})()
}
</script>
<template>
<div class="security-page">
<SecurityPanel
:profile="profile"
@change-password="pwdVisible = true"
@bind-phone="onBindPhone"
@bind-email="onBindEmail"
@unbind-email="unbindVisible = true"
@delete-account="deleteVisible = true"
/>
<ChangePasswordDialog
v-model:visible="pwdVisible"
:phone="profile?.phone ?? ''"
@submit="onChangePasswordSubmit"
/>
<BindContactDialog
v-model:visible="bindVisible"
:mode="bindMode"
:hint-phone="profile?.phone ?? ''"
:hint-email="profile?.email ?? ''"
@submit="onBindSubmit"
/>
<BindContactDialog
v-model:visible="identityVisible"
mode="identity"
:hint-phone="profile?.phone ?? ''"
@submit="onIdentitySubmit"
/>
<ConfirmDialog
v-model:visible="unbindVisible"
title="取消绑定"
message="确定要取消绑定该邮箱吗?取消后将无法通过邮箱接收通知。"
confirm-text="取消绑定"
cancel-text="暂不取消"
danger
@confirm="onUnbindConfirm"
/>
<ConfirmDialog
v-model:visible="deleteVisible"
title="注销账号"
message="确定要注销账号吗?"
confirm-text="注销账号"
cancel-text="暂不注销"
danger
@confirm="onDeleteConfirm"
/>
</div>
</template>
<style lang="less" scoped>
.security-page {
.flex(column, flex-start);
gap: @spacing-base;
}
</style>

View File

@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton, StepForm, UploadArea } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import type { BusinessType, FileInfo } from '@/types/api'
import { formatDate } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const { current } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const STEP_TITLES = ['选择业务类型', '填写问题描述', '补充备注']
const step = ref(0)
const businessTypeOptions: Array<{ label: string; value: BusinessType }> = [
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
const typeChips = ['BUG问题', '功能新增', '其他']
const businessType = ref<BusinessType | ''>('')
const projectName = ref('')
const type = ref('')
const description = ref('')
const remark = ref('')
const attachments = ref<FileInfo[]>([])
const projectNamePlaceholder = computed(() => current.value?.projectName || '默认当前项目')
const descriptionCount = computed(() => `${description.value.length}/500`)
const remarkCount = computed(() => `${remark.value.length}/500`)
function nextStep(): void {
if (step.value < STEP_TITLES.length - 1) step.value += 1
}
function prevStep(): void {
if (step.value > 0) step.value -= 1
}
function pickType(chip: string): void {
type.value = chip
}
function onUpload(file: File): void {
attachments.value.push({
name: file.name,
size: file.size,
uploadTime: formatDate(new Date()),
url: '',
})
}
function removeAttachment(index: number): void {
attachments.value.splice(index, 1)
}
function resetForm(): void {
step.value = 0
businessType.value = ''
projectName.value = ''
type.value = ''
description.value = ''
remark.value = ''
attachments.value = []
}
function onCancel(): void {
void router.push(`/project/${projectId.value}`)
}
async function onSubmit(): Promise<void> {
const effectiveName = projectName.value || current.value?.projectName || ''
if (!effectiveName) {
ElMessage.warning('请输入项目名称')
return
}
if (!type.value) {
ElMessage.warning('请选择或输入售后类型')
return
}
if (!description.value) {
ElMessage.warning('请填写问题描述')
return
}
await projectStore.submitAfterSales({
type: type.value,
description: description.value,
remark: remark.value,
attachments: attachments.value,
})
await router.push(`/project/${projectId.value}`)
}
onMounted(() => {
if (!current.value || current.value.id !== projectId.value) {
void projectStore.fetchDetail(projectId.value)
}
})
</script>
<template>
<div class="after-sales">
<nav class="after-sales__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
</RouterLink>
<span> &gt; </span>
<RouterLink :to="`/project/${projectId}`" class="link">
项目详情
</RouterLink>
<span> &gt; </span>
<span>申请售后</span>
</nav>
<StepForm :titles="STEP_TITLES" :current="step">
<!-- 第一步:选择业务类型 -->
<div v-if="step === 0" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label">业务类型</label>
<div class="after-sales__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="after-sales__option"
:class="{ 'is-active': businessType === option.value }"
@click="businessType = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-project">项目名称</label>
<input
id="after-sales-project"
v-model="projectName"
class="after-sales__input"
:placeholder="projectNamePlaceholder"
>
</div>
</div>
<!-- 第二步:填写问题描述 -->
<div v-if="step === 1" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-type">售后类型</label>
<input
id="after-sales-type"
v-model="type"
class="after-sales__input"
placeholder="选择下方推荐的售后类型或手动输入售后类型"
>
<div class="after-sales__options">
<button
v-for="chip in typeChips"
:key="chip"
class="after-sales__option"
:class="{ 'is-active': type === chip }"
@click="pickType(chip)"
>
{{ chip }}
</button>
</div>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-desc">问题描述</label>
<textarea
id="after-sales-desc"
v-model="description"
class="after-sales__textarea"
maxlength="500"
rows="5"
placeholder="请详细描述您的问题..."
/>
<span class="after-sales__count text-secondary">{{ descriptionCount }}</span>
</div>
<div class="after-sales__field">
<label class="after-sales__label">附件上传</label>
<UploadArea hint="支持图片/文档/压缩包,大小不超过1G" @upload="onUpload" />
<ul v-if="attachments.length > 0" class="after-sales__files">
<li
v-for="(file, index) in attachments"
:key="`${file.name}-${index}`"
class="after-sales__file"
>
<span class="after-sales__file-name" :title="file.name">{{ file.name }}</span>
<button
class="after-sales__file-remove"
@click="removeAttachment(index)"
>
移除
</button>
</li>
</ul>
</div>
</div>
<!-- 第三步:补充备注 -->
<div v-if="step === 2" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
<textarea
id="after-sales-remark"
v-model="remark"
class="after-sales__textarea"
maxlength="500"
rows="5"
placeholder="其他需要说明的事项..."
/>
<span class="after-sales__count text-secondary">{{ remarkCount }}</span>
<p class="after-sales__tip text-secondary">
备注为选填项,如有与问题描述相关的补充信息可在此说明,便于工程师更快定位问题。
</p>
</div>
</div>
</StepForm>
<div class="after-sales__footer">
<AppButton v-if="step > 0" @click="prevStep">
上一步
</AppButton>
<AppButton v-if="step < STEP_TITLES.length - 1" @click="nextStep">
下一步
</AppButton>
<AppButton v-if="step === STEP_TITLES.length - 1" type="primary" @click="onSubmit">
申请售后
</AppButton>
<AppButton @click="resetForm">
重置
</AppButton>
<AppButton @click="onCancel">
取消
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.after-sales {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__section {
.flex(column, flex-start);
gap: @spacing-lg;
}
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__option {
height: 30px;
padding: 0 @spacing-base;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
&__input {
width: 360px;
height: 36px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
}
&__textarea {
width: 100%;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
font-size: @font-size-base;
line-height: 1.6;
resize: vertical;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
}
&__count {
align-self: flex-end;
font-size: @font-size-sm;
}
&__tip {
font-size: @font-size-sm;
line-height: 1.6;
}
&__files {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__file {
.flex(row, space-between);
gap: @spacing-base;
width: 360px;
padding: @spacing-xs @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @bg-color-light;
}
&__file-name {
font-size: @font-size-sm;
color: @text-color;
.ellipsis();
}
&__file-remove {
flex-shrink: 0;
font-size: @font-size-sm;
color: @danger-color;
}
&__footer {
.flex(row, flex-start);
gap: @spacing-sm;
}
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AfterSales } from '@/types/business/project'
interface Props {
afterSales?: AfterSales
projectId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
apply: []
cancel: []
}>()
const isProcessing = computed(() => props.afterSales?.status === '处理中')
</script>
<template>
<section class="project-after-sales">
<!-- 处理中状态卡片 -->
<div
v-if="isProcessing && afterSales"
class="project-after-sales__card project-after-sales__card--active"
>
<div class="project-after-sales__card-head">
<span class="project-after-sales__badge">处理中</span>
<h3 class="project-after-sales__card-title">
售后工单处理中
</h3>
</div>
<p class="project-after-sales__desc">
工程师正在处理您的修改需求,请耐心等待
</p>
<div class="project-after-sales__meta text-secondary">
<span v-if="afterSales.type">类型:{{ afterSales.type }}</span>
<span v-if="afterSales.createTime">提交时间:{{ afterSales.createTime }}</span>
</div>
<p v-if="afterSales.description" class="project-after-sales__desc">
{{ afterSales.description }}
</p>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--default"
@click="emit('cancel')"
>
取消售后
</button>
</div>
</div>
<!-- 空状态 -->
<div v-else class="project-after-sales__card project-after-sales__card--empty">
<p class="project-after-sales__empty text-secondary">
暂无售后工单
</p>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--primary"
@click="emit('apply')"
>
申请售后
</button>
</div>
</div>
</section>
</template>
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__card {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
&--active {
border-color: fade(@warning-color, 40%);
background: fade(@warning-color, 6%);
}
}
&__card-head {
.flex(row, center);
gap: @spacing-sm;
}
&__badge {
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: fade(@warning-color, 12%);
color: @warning-color;
font-size: @font-size-sm;
}
&__card-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-base;
color: @text-color;
line-height: 1.6;
}
&__meta {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
font-size: @font-size-sm;
}
&__empty {
padding: @spacing-lg 0;
text-align: center;
font-size: @font-size-base;
}
&__actions {
.flex(row, flex-end);
gap: @spacing-sm;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border-radius: @radius-base;
font-size: @font-size-base;
transition: all 0.2s;
&--primary {
border: none;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
}
}
&--default {
border: 1px solid @border-color-dark;
background: @white;
color: @text-color;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
}
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import { formatFileSize } from '@/utils/format'
interface Props {
files: FileInfo[]
}
defineProps<Props>()
const emit = defineEmits<{
upload: [file: File]
delete: [index: number]
preview: [index: number]
download: [index: number]
}>()
</script>
<template>
<section class="project-files">
<h2 class="project-files__title">
项目文件
</h2>
<UploadArea
hint="支持图片/文档/压缩包,大小不超过1G"
@upload="emit('upload', $event)"
/>
<ul class="project-files__list">
<li
v-for="(file, index) in files"
:key="`${file.name}-${index}`"
class="project-files__item"
>
<div class="project-files__info">
<span class="project-files__name" :title="file.name">{{ file.name }}</span>
<span class="project-files__meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
</span>
</div>
<div class="project-files__actions">
<button class="project-files__btn" @click="emit('preview', index)">
预览
</button>
<button class="project-files__btn" @click="emit('download', index)">
下载
</button>
<button
class="project-files__btn project-files__btn--danger"
@click="emit('delete', index)"
>
删除
</button>
</div>
</li>
<li v-if="files.length === 0" class="project-files__empty text-secondary">
暂无项目文件
</li>
</ul>
</section>
</template>
<style lang="less" scoped>
.project-files {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__item {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__meta {
font-size: @font-size-sm;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__btn {
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--danger {
color: @danger-color;
}
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { ProjectQuery, ProjectStatus } from '@/types/business/project'
interface Props {
query: ProjectQuery
total: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: ProjectQuery]
search: []
reset: []
}>()
const businessTypeOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
const statusOptions: Array<{ label: string; value: ProjectStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待立项', value: '待立项' },
{ label: '进行中', value: '进行中' },
{ label: '待验收', value: '待验收' },
{ label: '已完成', value: '已完成' },
{ label: '售后中', value: '售后中' },
{ label: '已取消', value: '已取消' },
]
const timeShortcuts: Array<{ label: string; value: ProjectQuery['timeShortcut'] }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.businessType !== 'all') {
list.push({ key: 'businessType', label: formatBusinessType(props.query.businessType) })
}
if (props.query.status !== 'all') {
list.push({ key: 'status', label: props.query.status })
}
if (props.query.timeShortcut !== 'all') {
const label = timeShortcuts.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
list.push({ key: 'time', label })
}
return list
})
function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: 'all' })
else if (key === 'status') patch({ status: 'all' })
else if (key === 'time') patch({ timeShortcut: 'all' })
}
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: 'all',
status: 'all',
timeShortcut: 'all',
})
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">业务类型</span>
<div class="project-filter__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.businessType === option.value }"
@click="patch({ businessType: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 项目状态(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">项目状态</span>
<div class="project-filter__options">
<button
v-for="option in statusOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.status === option.value }"
@click="patch({ status: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 提交时间(快捷区间) -->
<div class="project-filter__group">
<span class="project-filter__group-title">提交时间</span>
<div class="project-filter__options">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="project-filter__option"
:class="{ 'is-active': query.timeShortcut === shortcut.value }"
@click="patch({ timeShortcut: shortcut.value })"
>
{{ shortcut.label }}
</button>
</div>
</div>
</FilterPanel>
</template>
<style lang="less" scoped>
.project-filter {
&__group {
.flex(row, flex-start);
gap: @spacing-base;
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
}
</style>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FileInfo } from '@/types/api'
import type { ProjectBill } from '@/types/business/project'
import { formatFileSize, formatMoney } from '@/utils/format'
interface Props {
paymentPlans: ProjectBill[]
invoiceRecords: FileInfo[]
contractFile?: FileInfo
}
defineProps<Props>()
const emit = defineEmits<{
pay: [billId: string]
preview: [file: FileInfo]
download: [file: FileInfo]
}>()
const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'已支付': 'success',
'待支付': 'warning',
'未到期': 'info',
}
</script>
<template>
<section class="project-finance">
<!-- 付款计划 -->
<h2 class="project-finance__title">
付款计划
</h2>
<div class="project-finance__plans">
<article
v-for="bill in paymentPlans"
:key="bill.id"
class="project-finance__plan"
>
<div class="project-finance__plan-head">
<span class="project-finance__plan-name">{{ bill.planName }}</span>
<StatusTag :label="bill.status" :variant="BILL_VARIANTS[bill.status]" />
</div>
<div class="project-finance__plan-meta">
<div class="project-finance__field">
<span class="project-finance__label text-secondary">关联项目</span>
<span class="project-finance__value">{{ bill.projectName }}</span>
</div>
<div class="project-finance__field">
<span class="project-finance__label text-secondary">账单号</span>
<span class="project-finance__value">{{ bill.billNo }}</span>
</div>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
<div class="project-finance__plan-foot">
<span v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__trade text-secondary">
交易流水号:{{ bill.tradeNo }}
</span>
<button
v-if="bill.status === '待支付'"
class="project-finance__pay"
@click="emit('pay', bill.id)"
>
立即支付
</button>
</div>
</article>
<p v-if="paymentPlans.length === 0" class="project-finance__empty text-secondary">
暂无付款计划
</p>
</div>
<!-- 发票记录 -->
<h2 class="project-finance__title">
发票记录
</h2>
<ul class="project-finance__files">
<li
v-for="(file, index) in invoiceRecords"
:key="`invoice-${index}`"
class="project-finance__file"
>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', file)">
预览
</button>
<button class="project-finance__link" @click="emit('download', file)">
下载
</button>
</div>
</li>
<li v-if="invoiceRecords.length === 0" class="project-finance__empty text-secondary">
暂无发票记录
</li>
</ul>
<!-- 合同文件 -->
<h2 class="project-finance__title">
合同文件
</h2>
<div v-if="contractFile" class="project-finance__contract">
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(contractFile.size) }} · {{ contractFile.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', contractFile)">
预览
</button>
<button class="project-finance__link" @click="emit('download', contractFile)">
下载
</button>
</div>
</div>
<p v-else class="project-finance__empty text-secondary">
暂无合同文件
</p>
</section>
</template>
<style lang="less" scoped>
.project-finance {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__plans {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__plan {
.flex(column, flex-start);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__plan-head {
.flex(row, space-between, center);
gap: @spacing-sm;
}
&__plan-name {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__plan-meta {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__field {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-sm;
}
&__label {
flex-shrink: 0;
}
&__value {
color: @text-color;
.ellipsis();
}
&__amount {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__plan-foot {
.flex(row, space-between, center);
min-height: 28px;
}
&__trade {
font-size: @font-size-sm;
.ellipsis();
}
&__pay {
height: 28px;
padding: 0 @spacing-base;
border: none;
border-radius: @radius-sm;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__files {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__file,
&__contract {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__file-info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__file-name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__file-meta {
font-size: @font-size-sm;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__link {
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
interface Props {
project: Project
}
defineProps<Props>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
}
</script>
<template>
<div class="project-info-card">
<div class="project-info-card__head">
<StatusTag :label="project.status" :variant="STATUS_VARIANTS[project.status]" />
<h1 class="project-info-card__name">
{{ project.projectName }}
</h1>
</div>
<div class="project-info-card__meta">
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">项目编号</span>
<span class="project-info-card__value">{{ project.projectNo }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">业务类型</span>
<span class="project-info-card__value">{{ formatBusinessType(project.businessType) }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">提交时间</span>
<span class="project-info-card__value">{{ project.submitTime }}</span>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.project-info-card {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
}
&__name {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
margin-top: @spacing-base;
}
&__meta-item {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__label {
font-size: @font-size-sm;
}
&__value {
font-size: @font-size-base;
color: @heading-color;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { Milestone } from '@/types/business/project'
interface Props {
progress: number
milestones: Milestone[]
startTime?: string
endTime?: string
}
defineProps<Props>()
</script>
<template>
<section class="project-milestones">
<div class="project-milestones__head">
<h2 class="project-milestones__title">
总体进度
</h2>
<span class="project-milestones__percent">{{ progress }}%</span>
</div>
<div class="project-milestones__track">
<div
class="project-milestones__bar"
:style="{ width: `${progress}%` }"
/>
</div>
<ol class="project-milestones__timeline">
<li
v-for="node in milestones"
:key="node.name"
class="project-milestones__node"
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
>
<span class="project-milestones__dot" />
<div class="project-milestones__body">
<div class="project-milestones__row">
<span class="project-milestones__name">{{ node.name }}</span>
<span class="project-milestones__status">{{ node.status }}</span>
</div>
<span v-if="node.time" class="project-milestones__time text-secondary">{{ node.time }}</span>
</div>
</li>
<li v-if="milestones.length === 0" class="project-milestones__empty text-secondary">
暂无里程碑数据
</li>
</ol>
<div class="project-milestones__range">
<span v-if="startTime" class="text-secondary">开始时间:{{ startTime }}</span>
<span v-if="endTime" class="text-secondary">结束时间:{{ endTime }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.project-milestones {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__percent {
font-size: @font-size-xl;
font-weight: 600;
color: @primary-color;
}
&__track {
height: 8px;
margin-top: @spacing-base;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__timeline {
margin-top: @spacing-lg;
padding-left: @spacing-xs;
}
&__node {
position: relative;
padding: 0 0 @spacing-base @spacing-lg;
border-left: 2px solid @border-color;
&:last-of-type {
border-left-color: transparent;
padding-bottom: 0;
}
&.is-done {
border-left-color: @success-color;
}
&.is-active {
border-left-color: @primary-color;
}
}
&__dot {
position: absolute;
top: 2px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid @border-color-dark;
border-radius: @radius-round;
background: @white;
.is-done & {
border-color: @success-color;
background: @success-color;
}
.is-active & {
border-color: @primary-color;
background: @primary-color;
}
}
&__body {
.flex(column, flex-start);
gap: 2px;
}
&__row {
.flex(row, center);
gap: @spacing-sm;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
font-weight: 600;
}
&__status {
font-size: @font-size-sm;
color: @text-color-secondary;
.is-done & {
color: @success-color;
}
.is-active & {
color: @primary-color;
}
}
&__time {
font-size: @font-size-sm;
}
&__empty {
font-size: @font-size-base;
}
&__range {
.flex(row, flex-start, center);
gap: @spacing-lg;
margin-top: @spacing-base;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
font-size: @font-size-sm;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
interface Props {
projects: Project[]
}
defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
afterSales: [id: string]
}>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
}
</script>
<template>
<div class="project-table">
<table class="project-table__grid">
<thead>
<tr>
<th>序号</th>
<th>项目编号</th>
<th>项目名称</th>
<th>业务类型</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in projects" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.projectNo }}</td>
<td class="project-table__name" :title="item.projectName">
{{ item.projectName }}
</td>
<td>{{ formatBusinessType(item.businessType) }}</td>
<td>
<div class="project-table__progress">
<div class="project-table__track">
<div
class="project-table__bar"
:style="{ width: `${item.progress}%` }"
/>
</div>
<span class="project-table__percent">{{ item.progress }}%</span>
</div>
</td>
<td>
<StatusTag :label="item.status" :variant="STATUS_VARIANTS[item.status]" />
</td>
<td class="project-table__actions">
<button class="project-table__btn" @click="emit('view', item.id)">
详情
</button>
<button
v-if="item.status === '已完成'"
class="project-table__btn project-table__btn--accent"
@click="emit('afterSales', item.id)"
>
申请售后
</button>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="7" class="project-table__empty text-secondary">
暂无符合条件的项目
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="less" scoped>
.project-table {
overflow-x: auto;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.scrollbar();
&__grid {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
th,
td {
padding: @spacing-base @spacing-base;
text-align: left;
border-bottom: 1px solid @border-color;
white-space: nowrap;
}
th {
background: @bg-color-light;
color: @heading-color;
font-weight: 600;
}
td {
color: @text-color;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: @bg-color-light;
}
}
&__name {
max-width: 220px;
color: @heading-color;
.ellipsis();
}
&__progress {
.flex(row, center);
gap: @spacing-sm;
min-width: 140px;
}
&__track {
flex: 1;
height: 6px;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__percent {
flex-shrink: 0;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
}
&__btn {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--accent {
color: @warning-color;
}
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { TeamMember } from '@/types/business/project'
interface Props {
team: TeamMember[]
}
defineProps<Props>()
</script>
<template>
<section class="project-team">
<h2 class="project-team__title">
项目团队
</h2>
<div class="project-team__list">
<div
v-for="(member, index) in team"
:key="`${member.name}-${index}`"
class="project-team__card"
>
<span class="project-team__avatar">{{ member.avatarChar }}</span>
<div class="project-team__info">
<span class="project-team__role text-secondary">{{ member.role }}</span>
<span class="project-team__name">{{ member.name }}</span>
</div>
</div>
<p v-if="team.length === 0" class="project-team__empty text-secondary">
暂无团队成员
</p>
</div>
</section>
</template>
<style lang="less" scoped>
.project-team {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-base;
}
&__card {
.flex(row, center);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-lg;
font-weight: 600;
}
&__info {
.flex(column, flex-start);
gap: 2px;
}
&__role {
font-size: @font-size-sm;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ConfirmDialog } from '@/components'
import type { FileInfo } from '@/types/api'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
import ProjectTeam from './cpns/ProjectTeam.vue'
import ProjectMilestones from './cpns/ProjectMilestones.vue'
import ProjectFiles from './cpns/ProjectFiles.vue'
import ProjectFinance from './cpns/ProjectFinance.vue'
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
type DetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const { current } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const activeTab = ref<DetailTab>('progress')
const cancelVisible = ref(false)
const tabs: Array<{ label: string; value: DetailTab }> = [
{ label: '项目进度', value: 'progress' },
{ label: '项目文件', value: 'files' },
{ label: '合同财务', value: 'finance' },
{ label: '售后信息', value: 'afterSales' },
]
function load(): void {
void projectStore.fetchDetail(projectId.value)
}
function onUpload(file: File): void {
void projectStore.uploadFile(file).then(() => {
ElMessage.success('文件已上传')
})
}
function onDeleteFile(index: number): void {
void projectStore.deleteFile(index).then(() => {
ElMessage.success('文件已删除')
})
}
function onPreviewFile(): void {
// TODO(api): 文件预览接口
ElMessage.info('文件预览待接口接入')
}
function onDownloadFile(): void {
// TODO(api): 文件下载接口
ElMessage.info('文件下载待接口接入')
}
function onPay(): void {
// TODO: 跳财务中心 `/finance/bills`
ElMessage.info('请前往财务中心完成支付')
}
function onFinancePreview(file: FileInfo): void {
// TODO(api): 发票/合同文件预览接口
ElMessage.info(`「${file.name}」预览待接口接入`)
}
function onFinanceDownload(file: FileInfo): void {
// TODO(api): 发票/合同文件下载接口
ElMessage.info(`「${file.name}」下载待接口接入`)
}
function onApplyAfterSales(): void {
void router.push(`/project/${projectId.value}/after-sales`)
}
function onConfirmCancel(): void {
void projectStore.cancelAfterSales()
}
watch(projectId, load)
onMounted(load)
</script>
<template>
<div class="project-detail">
<nav class="project-detail__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
</RouterLink>
<span> &gt; </span>
<span>项目详情</span>
</nav>
<template v-if="current">
<ProjectInfoCard :project="current" />
<ProjectTeam :team="current.team" />
<!-- 本地 Tab 切换 -->
<div class="project-detail__tabs">
<button
v-for="tab in tabs"
:key="tab.value"
class="project-detail__tab"
:class="{ 'is-active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<ProjectMilestones
v-if="activeTab === 'progress'"
:progress="current.progress"
:milestones="current.milestones"
:start-time="current.startTime"
:end-time="current.endTime"
/>
<ProjectFiles
v-if="activeTab === 'files'"
:files="current.files"
@upload="onUpload"
@delete="onDeleteFile"
@preview="onPreviewFile"
@download="onDownloadFile"
/>
<ProjectFinance
v-if="activeTab === 'finance'"
:payment-plans="current.paymentPlans"
:invoice-records="current.invoiceRecords"
:contract-file="current.contractFile"
@pay="onPay"
@preview="onFinancePreview"
@download="onFinanceDownload"
/>
<ProjectAfterSales
v-if="activeTab === 'afterSales'"
:after-sales="current.afterSales"
:project-id="current.id"
@apply="onApplyAfterSales"
@cancel="cancelVisible = true"
/>
</template>
<p v-else class="project-detail__empty text-secondary">
项目不存在或已取消
</p>
<ConfirmDialog
v-model:visible="cancelVisible"
title="取消售后"
message="确定取消当前售后工单吗?"
confirm-text="确定取消"
danger
@confirm="onConfirmCancel"
/>
</div>
</template>
<style lang="less" scoped>
.project-detail {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-xs;
padding: @spacing-xs;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: none;
border-radius: @radius-base;
background: transparent;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
&__empty {
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,9 @@
export { default } from './index.vue'
export { default as ProjectFilter } from './cpns/ProjectFilter.vue'
export { default as ProjectTable } from './cpns/ProjectTable.vue'
export { default as ProjectInfoCard } from './cpns/ProjectInfoCard.vue'
export { default as ProjectTeam } from './cpns/ProjectTeam.vue'
export { default as ProjectMilestones } from './cpns/ProjectMilestones.vue'
export { default as ProjectFiles } from './cpns/ProjectFiles.vue'
export { default as ProjectFinance } from './cpns/ProjectFinance.vue'
export { default as ProjectAfterSales } from './cpns/ProjectAfterSales.vue'

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useProjectStore } from '@/stores/modules/project'
import type { ProjectQuery } from '@/types/business/project'
import ProjectFilter from './cpns/ProjectFilter.vue'
import ProjectTable from './cpns/ProjectTable.vue'
const router = useRouter()
const projectStore = useProjectStore()
const { query, list, total } = storeToRefs(projectStore)
function onUpdateQuery(next: ProjectQuery): void {
projectStore.setQuery(next)
}
function onSearch(): void {
void projectStore.search()
}
function onReset(): void {
void projectStore.resetQuery()
}
function onPageChange(page: number): void {
void projectStore.changePage(page)
}
function onView(id: string): void {
void router.push(`/project/${id}`)
}
function onAfterSales(id: string): void {
void router.push(`/project/${id}/after-sales`)
}
onMounted(() => {
void projectStore.fetchList()
})
</script>
<template>
<div class="project">
<header class="project__head">
<h1 class="project__title">
项目中心
</h1>
<p class="project__slogan text-secondary">
查看和管理您的所有项目,实时跟踪项目进度
</p>
</header>
<ProjectFilter
:query="query"
:total="total"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<ProjectTable
:projects="list"
@view="onView"
@after-sales="onAfterSales"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.project {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { formatFileSize } from '@/utils/format'
interface FilePayload {
name: string
size: number
}
interface Props {
file: FilePayload
}
defineProps<Props>()
const emit = defineEmits<{ download: [] }>()
</script>
<template>
<div class="chat-file-card">
<span class="chat-file-card__icon">📄</span>
<div class="chat-file-card__info">
<span class="chat-file-card__name" :title="file.name">{{ file.name }}</span>
<span class="chat-file-card__size text-secondary">{{ formatFileSize(file.size) }}</span>
</div>
<button class="chat-file-card__download" @click="emit('download')">
下载
</button>
</div>
</template>
<style lang="less" scoped>
.chat-file-card {
.flex(row, center);
gap: @spacing-sm;
min-width: 220px;
max-width: 320px;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
&__icon {
font-size: @font-size-xl;
line-height: 1;
}
&__info {
.flex(column, flex-start);
gap: 2px;
flex: 1;
min-width: 0;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__size {
font-size: @font-size-sm;
}
&__download {
flex-shrink: 0;
height: 26px;
padding: 0 @spacing-sm;
border: 1px solid @primary-color;
border-radius: @radius-sm;
background: @white;
color: @primary-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
background: @primary-light;
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton } from '@/components'
/** 单文件大小上限 1GB */
const MAX_FILE_SIZE = 1024 * 1024 * 1024
const content = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const emit = defineEmits<{
send: [content: string]
sendFile: [file: File]
}>()
async function doSend(): Promise<void> {
const text = content.value.trim()
if (!text) return
emit('send', text)
content.value = ''
await nextTick()
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void doSend()
}
}
function openFilePicker(): void {
fileInputRef.value?.click()
}
function onFileChange(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const file = target.files?.[0]
target.value = ''
if (!file) return
if (file.size > MAX_FILE_SIZE) {
ElMessage.warning('文件大小不能超过1G')
return
}
emit('sendFile', file)
}
</script>
<template>
<div class="chat-input">
<textarea
v-model="content"
class="chat-input__textarea"
placeholder="输入消息,按Enter发送,Shift+Enter换行..."
rows="3"
@keydown="onKeydown"
/>
<div class="chat-input__bar">
<span class="chat-input__hint text-secondary">
按Enter发送 | 支持拖拽上传文件
</span>
<div class="chat-input__actions">
<button
type="button"
class="chat-input__attach"
aria-label="上传附件"
title="上传附件"
@click="openFilePicker"
>
📎
</button>
<input
ref="fileInputRef"
type="file"
class="chat-input__file"
@change="onFileChange"
>
<AppButton type="primary" @click="doSend">
发送
</AppButton>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.chat-input {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-base;
border-top: 1px solid @border-color;
background: @white;
&__textarea {
width: 100%;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
line-height: 1.6;
resize: none;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-disabled;
}
}
&__bar {
.flex(row, space-between);
gap: @spacing-base;
}
&__hint {
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-end, center);
gap: @spacing-sm;
}
&__attach {
.flex(row, center);
width: 32px;
height: 32px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
font-size: @font-size-lg;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
}
&__file {
display: none;
}
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { ChatMessage } from '@/types/business/service'
import ChatFileCard from './ChatFileCard.vue'
interface Props {
message: ChatMessage
unread?: boolean
}
withDefaults(defineProps<Props>(), { unread: false })
const emit = defineEmits<{ download: [] }>()
</script>
<template>
<div
class="chat-message-item"
:class="`chat-message-item--${message.role}`"
>
<span v-if="message.role === 'assistant'" class="chat-message-item__avatar">服</span>
<div class="chat-message-item__main">
<div class="chat-message-item__bubble">
<span v-if="unread" class="chat-message-item__unread">未读</span>
<ChatFileCard
v-if="message.file"
:file="message.file"
@download="emit('download')"
/>
<template v-else>{{ message.content }}</template>
</div>
<span class="chat-message-item__time text-secondary">{{ message.time }}</span>
</div>
<span v-if="message.role === 'user'" class="chat-message-item__avatar chat-message-item__avatar--user">王</span>
</div>
</template>
<style lang="less" scoped>
.chat-message-item {
.flex(row, flex-start);
gap: @spacing-sm;
&__avatar {
flex-shrink: 0;
.flex(row, center);
width: 36px;
height: 36px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
&--user {
background: fade(@success-color, 15%);
color: @success-color;
}
}
&__main {
.flex(column, flex-start);
gap: @spacing-xs;
max-width: 70%;
}
&__bubble {
position: relative;
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @white;
box-shadow: @shadow-base;
color: @text-color;
font-size: @font-size-base;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
&__time {
font-size: @font-size-sm;
}
&__unread {
position: absolute;
top: -8px;
right: -6px;
padding: 0 6px;
border-radius: @radius-round;
background: @danger-color;
color: @white;
font-size: @font-size-sm;
line-height: 16px;
}
&--user {
.flex(row, flex-end);
.chat-message-item__main {
align-items: flex-end;
}
.chat-message-item__bubble {
background: @primary-color;
color: @white;
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { ChatMessage } from '@/types/business/service'
import ChatMessageItem from './ChatMessageItem.vue'
import ChatInput from './ChatInput.vue'
interface Props {
messages: ChatMessage[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
send: [content: string]
sendFile: [file: File]
}>()
const scrollRef = ref<HTMLElement | null>(null)
/** 首条 assistant 消息标记未读 */
function isUnread(index: number): boolean {
const first = props.messages[0]
return index === 0 && first?.role === 'assistant'
}
async function scrollToBottom(): Promise<void> {
await nextTick()
const el = scrollRef.value
if (el) el.scrollTop = el.scrollHeight
}
function onDownload(): void {
ElMessage.info('下载待接口接入')
}
watch(
() => props.messages.length,
() => void scrollToBottom(),
)
</script>
<template>
<div class="chat-window">
<div ref="scrollRef" class="chat-window__scroll">
<template v-for="(item, index) in messages" :key="item.id">
<ChatMessageItem
:message="item"
:unread="isUnread(index)"
@download="onDownload"
/>
<div v-if="index === 0" class="chat-window__divider text-secondary">
—— 历史消息 ——
</div>
</template>
</div>
<ChatInput @send="emit('send', $event)" @send-file="emit('sendFile', $event)" />
</div>
</template>
<style lang="less" scoped>
.chat-window {
.flex(column, flex-start);
border-radius: @radius-lg;
background: @bg-color;
box-shadow: @shadow-base;
overflow: hidden;
&__scroll {
.flex(column, flex-start);
gap: @spacing-base;
max-height: 60vh;
min-height: 320px;
padding: @spacing-lg;
overflow-y: auto;
.scrollbar();
}
&__divider {
.flex(row, center);
gap: @spacing-base;
font-size: @font-size-sm;
text-align: center;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: @border-color;
}
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default } from './index.vue'
export { default as ChatWindow } from './cpns/ChatWindow.vue'
export { default as ChatMessageItem } from './cpns/ChatMessageItem.vue'
export { default as ChatFileCard } from './cpns/ChatFileCard.vue'
export { default as ChatInput } from './cpns/ChatInput.vue'

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useServiceStore } from '@/stores/modules/service'
import ChatWindow from './cpns/ChatWindow.vue'
const serviceStore = useServiceStore()
const { messages } = storeToRefs(serviceStore)
function onSend(content: string): void {
void serviceStore.send(content)
}
function onSendFile(file: File): void {
void serviceStore.sendFile(file)
}
onMounted(() => {
void serviceStore.fetchHistory()
})
</script>
<template>
<div class="service">
<header class="service__head">
<h1 class="service__title">
在线客服
</h1>
<p class="service__subtitle text-secondary">
在线客服为您提供需求咨询、项目进度查询、业务答疑,快速协助解决平台操作与项目相关问题。
</p>
</header>
<ChatWindow
:messages="messages"
@send="onSend"
@send-file="onSendFile"
/>
</div>
</template>
<style lang="less" scoped>
.service {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__subtitle {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
}
</style>