feat 搭建架构骨架

This commit is contained in:
Ironsp
2026-09-24 09:09:47 +08:00
parent dd9f6b77a4
commit 7a2efa3fea
180 changed files with 18967 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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