first
This commit is contained in:
35
src/views/404.vue
Normal file
35
src/views/404.vue
Normal 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>
|
||||
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal 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>
|
||||
57
src/views/cases/cpns/CaseRichText.vue
Normal file
57
src/views/cases/cpns/CaseRichText.vue
Normal 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>
|
||||
324
src/views/cases/cpns/CasesFilter.vue
Normal file
324
src/views/cases/cpns/CasesFilter.vue
Normal 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>
|
||||
43
src/views/cases/cpns/CasesGrid.vue
Normal file
43
src/views/cases/cpns/CasesGrid.vue
Normal 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>
|
||||
90
src/views/cases/detail.vue
Normal file
90
src/views/cases/detail.vue
Normal 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> > </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
5
src/views/cases/index.ts
Normal 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
90
src/views/cases/index.vue
Normal 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>
|
||||
216
src/views/contract/cpns/ContractInfoCard.vue
Normal file
216
src/views/contract/cpns/ContractInfoCard.vue
Normal 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>
|
||||
47
src/views/contract/cpns/ContractSignPanel.vue
Normal file
47
src/views/contract/cpns/ContractSignPanel.vue
Normal 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>
|
||||
161
src/views/contract/cpns/ContractTable.vue
Normal file
161
src/views/contract/cpns/ContractTable.vue
Normal 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>
|
||||
76
src/views/contract/cpns/SignSteps.vue
Normal file
76
src/views/contract/cpns/SignSteps.vue
Normal 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>
|
||||
98
src/views/contract/detail.vue
Normal file
98
src/views/contract/detail.vue
Normal 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> > </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>
|
||||
5
src/views/contract/index.ts
Normal file
5
src/views/contract/index.ts
Normal 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'
|
||||
95
src/views/contract/index.vue
Normal file
95
src/views/contract/index.vue
Normal 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>
|
||||
78
src/views/contract/sign.vue
Normal file
78
src/views/contract/sign.vue
Normal 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> > </span>
|
||||
<RouterLink :to="`/contract/${contractId}`" class="link">
|
||||
合同详情
|
||||
</RouterLink>
|
||||
<span> > </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>
|
||||
105
src/views/demand/cpns/DemandStep1Business.vue
Normal file
105
src/views/demand/cpns/DemandStep1Business.vue
Normal 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>
|
||||
229
src/views/demand/cpns/DemandStep2Info.vue
Normal file
229
src/views/demand/cpns/DemandStep2Info.vue
Normal 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>
|
||||
172
src/views/demand/cpns/DemandStep3Confirm.vue
Normal file
172
src/views/demand/cpns/DemandStep3Confirm.vue
Normal 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>
|
||||
4
src/views/demand/index.ts
Normal file
4
src/views/demand/index.ts
Normal 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
151
src/views/demand/index.vue
Normal 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>
|
||||
226
src/views/finance/bill-detail.vue
Normal file
226
src/views/finance/bill-detail.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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
158
src/views/finance/bills.vue
Normal 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> > </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>
|
||||
88
src/views/finance/cpns/BillSummaryBar.vue
Normal file
88
src/views/finance/cpns/BillSummaryBar.vue
Normal 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>
|
||||
182
src/views/finance/cpns/BillTable.vue
Normal file
182
src/views/finance/cpns/BillTable.vue
Normal 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>
|
||||
115
src/views/finance/cpns/FinanceChart.vue
Normal file
115
src/views/finance/cpns/FinanceChart.vue
Normal 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>
|
||||
136
src/views/finance/cpns/FinanceOverview.vue
Normal file
136
src/views/finance/cpns/FinanceOverview.vue
Normal 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>
|
||||
116
src/views/finance/cpns/InvoiceTitleCard.vue
Normal file
116
src/views/finance/cpns/InvoiceTitleCard.vue
Normal 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>
|
||||
192
src/views/finance/cpns/InvoiceTitleDialog.vue
Normal file
192
src/views/finance/cpns/InvoiceTitleDialog.vue
Normal 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>
|
||||
179
src/views/finance/cpns/PayMethodDialog.vue
Normal file
179
src/views/finance/cpns/PayMethodDialog.vue
Normal 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>
|
||||
8
src/views/finance/index.ts
Normal file
8
src/views/finance/index.ts
Normal 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
110
src/views/finance/index.vue
Normal 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>
|
||||
207
src/views/finance/invoice-titles.vue
Normal file
207
src/views/finance/invoice-titles.vue
Normal 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> > </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>
|
||||
364
src/views/finance/invoices.vue
Normal file
364
src/views/finance/invoices.vue
Normal 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> > </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">
|
||||
管理抬头 >
|
||||
</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>
|
||||
232
src/views/finance/pay-result.vue
Normal file
232
src/views/finance/pay-result.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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
291
src/views/finance/pay.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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>
|
||||
113
src/views/home/cpns/HomeBusinessCards.vue
Normal file
113
src/views/home/cpns/HomeBusinessCards.vue
Normal 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>
|
||||
106
src/views/home/cpns/HomeCaseShowcase.vue
Normal file
106
src/views/home/cpns/HomeCaseShowcase.vue
Normal 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>
|
||||
70
src/views/home/cpns/HomeQuickEntries.vue
Normal file
70
src/views/home/cpns/HomeQuickEntries.vue
Normal 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>
|
||||
129
src/views/home/cpns/HomeServiceTabs.vue
Normal file
129
src/views/home/cpns/HomeServiceTabs.vue
Normal 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>
|
||||
55
src/views/home/cpns/HomeStats.vue
Normal file
55
src/views/home/cpns/HomeStats.vue
Normal 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>
|
||||
74
src/views/home/cpns/HomeWelcome.vue
Normal file
74
src/views/home/cpns/HomeWelcome.vue
Normal 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
108
src/views/home/detail.vue
Normal 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> > </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
7
src/views/home/index.ts
Normal 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
58
src/views/home/index.vue
Normal 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>
|
||||
126
src/views/login/cpns/LoginForm.vue
Normal file
126
src/views/login/cpns/LoginForm.vue
Normal 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>
|
||||
145
src/views/login/cpns/SmsLoginForm.vue
Normal file
145
src/views/login/cpns/SmsLoginForm.vue
Normal 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
3
src/views/login/index.ts
Normal 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
232
src/views/login/index.vue
Normal 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>
|
||||
287
src/views/login/register.vue
Normal file
287
src/views/login/register.vue
Normal 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
278
src/views/login/reset.vue
Normal 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>
|
||||
106
src/views/message/cpns/MessageItem.vue
Normal file
106
src/views/message/cpns/MessageItem.vue
Normal 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>
|
||||
44
src/views/message/cpns/MessageList.vue
Normal file
44
src/views/message/cpns/MessageList.vue
Normal 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>
|
||||
95
src/views/message/cpns/MessageTabs.vue
Normal file
95
src/views/message/cpns/MessageTabs.vue
Normal 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>
|
||||
211
src/views/message/detail.vue
Normal file
211
src/views/message/detail.vue
Normal 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> > </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>
|
||||
4
src/views/message/index.ts
Normal file
4
src/views/message/index.ts
Normal 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
127
src/views/message/index.vue
Normal 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>
|
||||
37
src/views/profile/about.vue
Normal file
37
src/views/profile/about.vue
Normal 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>
|
||||
239
src/views/profile/address.vue
Normal file
239
src/views/profile/address.vue
Normal 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>
|
||||
161
src/views/profile/cpns/AboutPanel.vue
Normal file
161
src/views/profile/cpns/AboutPanel.vue
Normal 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>
|
||||
194
src/views/profile/cpns/AddressDialog.vue
Normal file
194
src/views/profile/cpns/AddressDialog.vue
Normal 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>
|
||||
229
src/views/profile/cpns/BindContactDialog.vue
Normal file
229
src/views/profile/cpns/BindContactDialog.vue
Normal 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>
|
||||
197
src/views/profile/cpns/ChangePasswordDialog.vue
Normal file
197
src/views/profile/cpns/ChangePasswordDialog.vue
Normal 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>
|
||||
43
src/views/profile/cpns/FavoritesGrid.vue
Normal file
43
src/views/profile/cpns/FavoritesGrid.vue
Normal 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>
|
||||
195
src/views/profile/cpns/FeedbackForm.vue
Normal file
195
src/views/profile/cpns/FeedbackForm.vue
Normal 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>
|
||||
120
src/views/profile/cpns/HelpFaq.vue
Normal file
120
src/views/profile/cpns/HelpFaq.vue
Normal 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>
|
||||
63
src/views/profile/cpns/HotlinePanel.vue
Normal file
63
src/views/profile/cpns/HotlinePanel.vue
Normal 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>
|
||||
97
src/views/profile/cpns/ProfileSider.vue
Normal file
97
src/views/profile/cpns/ProfileSider.vue
Normal 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>
|
||||
112
src/views/profile/cpns/ProfileUserCard.vue
Normal file
112
src/views/profile/cpns/ProfileUserCard.vue
Normal 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>
|
||||
124
src/views/profile/cpns/QualificationCard.vue
Normal file
124
src/views/profile/cpns/QualificationCard.vue
Normal 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>
|
||||
264
src/views/profile/cpns/QualificationDialog.vue
Normal file
264
src/views/profile/cpns/QualificationDialog.vue
Normal 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>
|
||||
290
src/views/profile/cpns/SecurityPanel.vue
Normal file
290
src/views/profile/cpns/SecurityPanel.vue
Normal 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>
|
||||
138
src/views/profile/favorites.vue
Normal file
138
src/views/profile/favorites.vue
Normal 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>
|
||||
87
src/views/profile/feedback.vue
Normal file
87
src/views/profile/feedback.vue
Normal 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>
|
||||
56
src/views/profile/help.vue
Normal file
56
src/views/profile/help.vue
Normal 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>
|
||||
15
src/views/profile/hotline.vue
Normal file
15
src/views/profile/hotline.vue
Normal 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>
|
||||
14
src/views/profile/index.ts
Normal file
14
src/views/profile/index.ts
Normal 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'
|
||||
73
src/views/profile/index.vue
Normal file
73
src/views/profile/index.vue
Normal 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>
|
||||
149
src/views/profile/qualifications.vue
Normal file
149
src/views/profile/qualifications.vue
Normal 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>
|
||||
147
src/views/profile/security.vue
Normal file
147
src/views/profile/security.vue
Normal 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>
|
||||
377
src/views/project/after-sales.vue
Normal file
377
src/views/project/after-sales.vue
Normal 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> > </span>
|
||||
<RouterLink :to="`/project/${projectId}`" class="link">
|
||||
项目详情
|
||||
</RouterLink>
|
||||
<span> > </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>
|
||||
166
src/views/project/cpns/ProjectAfterSales.vue
Normal file
166
src/views/project/cpns/ProjectAfterSales.vue
Normal 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>
|
||||
134
src/views/project/cpns/ProjectFiles.vue
Normal file
134
src/views/project/cpns/ProjectFiles.vue
Normal 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>
|
||||
191
src/views/project/cpns/ProjectFilter.vue
Normal file
191
src/views/project/cpns/ProjectFilter.vue
Normal 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>
|
||||
275
src/views/project/cpns/ProjectFinance.vue
Normal file
275
src/views/project/cpns/ProjectFinance.vue
Normal 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>
|
||||
86
src/views/project/cpns/ProjectInfoCard.vue
Normal file
86
src/views/project/cpns/ProjectInfoCard.vue
Normal 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>
|
||||
186
src/views/project/cpns/ProjectMilestones.vue
Normal file
186
src/views/project/cpns/ProjectMilestones.vue
Normal 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>
|
||||
183
src/views/project/cpns/ProjectTable.vue
Normal file
183
src/views/project/cpns/ProjectTable.vue
Normal 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>
|
||||
93
src/views/project/cpns/ProjectTeam.vue
Normal file
93
src/views/project/cpns/ProjectTeam.vue
Normal 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>
|
||||
214
src/views/project/detail.vue
Normal file
214
src/views/project/detail.vue
Normal 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> > </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>
|
||||
9
src/views/project/index.ts
Normal file
9
src/views/project/index.ts
Normal 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'
|
||||
97
src/views/project/index.vue
Normal file
97
src/views/project/index.vue
Normal 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>
|
||||
80
src/views/service/cpns/ChatFileCard.vue
Normal file
80
src/views/service/cpns/ChatFileCard.vue
Normal 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>
|
||||
152
src/views/service/cpns/ChatInput.vue
Normal file
152
src/views/service/cpns/ChatInput.vue
Normal 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>
|
||||
109
src/views/service/cpns/ChatMessageItem.vue
Normal file
109
src/views/service/cpns/ChatMessageItem.vue
Normal 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>
|
||||
95
src/views/service/cpns/ChatWindow.vue
Normal file
95
src/views/service/cpns/ChatWindow.vue
Normal 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>
|
||||
5
src/views/service/index.ts
Normal file
5
src/views/service/index.ts
Normal 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'
|
||||
61
src/views/service/index.vue
Normal file
61
src/views/service/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user