feat 搭建架构骨架
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user