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,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>