功能、UI更新
This commit is contained in:
3
components.d.ts
vendored
3
components.d.ts
vendored
@@ -7,6 +7,7 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BackFab: typeof import('./src/components/BackFab.vue')['default']
|
||||
CardTile: typeof import('./src/components/card/CardTile.vue')['default']
|
||||
Confetti: typeof import('./src/components/Confetti.vue')['default']
|
||||
CountUp: typeof import('./src/components/CountUp.vue')['default']
|
||||
@@ -32,7 +33,7 @@ declare module 'vue' {
|
||||
VipDock: typeof import('./src/components/vip/VipDock.vue')['default']
|
||||
VipFloatAd: typeof import('./src/components/vip/VipFloatAd.vue')['default']
|
||||
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
|
||||
WdDatetimePicker: typeof import('wot-design-uni/components/wd-datetime-picker/wd-datetime-picker.vue')['default']
|
||||
WdDatetimePickerView: typeof import('wot-design-uni/components/wd-datetime-picker-view/wd-datetime-picker-view.vue')['default']
|
||||
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
|
||||
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
|
||||
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"browserslist": [
|
||||
"iOS >= 15",
|
||||
"Android >= 8",
|
||||
"Chrome >= 79"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "uni",
|
||||
"dev:app": "uni -p app",
|
||||
|
||||
77
src/App.vue
77
src/App.vue
@@ -26,6 +26,15 @@ page {
|
||||
--tb-shadow-1: 0 2rpx 6rpx rgba(107, 47, 15, 0.05), 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
|
||||
--tb-shadow-2: 0 3rpx 10rpx rgba(107, 47, 15, 0.05), 0 16rpx 38rpx rgba(107, 47, 15, 0.1);
|
||||
--tb-shadow-3: 0 4rpx 12rpx rgba(107, 47, 15, 0.06), 0 22rpx 52rpx rgba(107, 47, 15, 0.13);
|
||||
/* 液态玻璃 token:色相全部沿用现有配色(白 + 暖棕影),只加透明度/模糊/高光层。
|
||||
透明度压低 + 模糊加强,让卡片真正「透出」背后的氛围色(Apple Liquid Glass 的核心)。 */
|
||||
--tb-glass-bg: rgba(255, 255, 255, 0.42);
|
||||
--tb-glass-bg-strong: rgba(255, 255, 255, 0.6);
|
||||
--tb-glass-bg-soft: rgba(255, 255, 255, 0.3);
|
||||
--tb-glass-line: rgba(255, 255, 255, 0.55);
|
||||
--tb-glass-hi: rgba(255, 255, 255, 0.85);
|
||||
--tb-glass-blur: 36rpx;
|
||||
--tb-glass-saturate: 1.9;
|
||||
/* 撞色点缀 */
|
||||
--tb-mint: #3ecfb2;
|
||||
--tb-lemon: #ffc53d;
|
||||
@@ -46,6 +55,15 @@ page {
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-size: 28rpx;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 氛围光斑底:同色系(橙/柠檬/mint/pink)径向渐变色场,
|
||||
给玻璃卡片的 backdrop-filter 提供「可透出的颜色」——液态玻璃感的前提。
|
||||
透明度够高才能透过玻璃看到颜色(之前太素,玻璃退化成白卡)。 */
|
||||
background-image:
|
||||
radial-gradient(1000rpx 700rpx at 110% -8%, rgba(255, 159, 77, 0.34), transparent 66%),
|
||||
radial-gradient(820rpx 600rpx at -16% 18%, rgba(255, 197, 61, 0.26), transparent 64%),
|
||||
radial-gradient(920rpx 680rpx at 114% 48%, rgba(62, 207, 178, 0.2), transparent 66%),
|
||||
radial-gradient(860rpx 640rpx at -14% 78%, rgba(255, 123, 169, 0.16), transparent 64%),
|
||||
radial-gradient(700rpx 520rpx at 52% 104%, rgba(255, 159, 77, 0.18), transparent 66%);
|
||||
}
|
||||
/* 图片工具隐藏工作画布(MP) */
|
||||
.hidden-canvas {
|
||||
@@ -128,13 +146,41 @@ page {
|
||||
margin-top: -120rpx;
|
||||
}
|
||||
|
||||
/* ============ 卡片 ============ */
|
||||
/* ============ 卡片(液态玻璃)============
|
||||
半透明白 + backdrop blur/saturate + 白高光描边 + 底部折射亮边;圆角不变。
|
||||
外阴影只用中性暖棕(Apple 玻璃不用彩色光晕),不支持 blur 的环境回落不透明白卡。 */
|
||||
.tb-card {
|
||||
background: var(--tb-card, #ffffff);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.42));
|
||||
border-radius: 36rpx;
|
||||
border: 1rpx solid var(--tb-card-line, rgba(255, 195, 155, 0.55));
|
||||
/* 升级(非推翻):环境影 + 近距离高光影 + 顶部内高光(R2-1) */
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), var(--tb-shadow-2);
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.55));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4), var(--tb-shadow-2);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.tb-card {
|
||||
background: var(--tb-card, #ffffff);
|
||||
border-color: var(--tb-card-line, rgba(255, 195, 155, 0.55));
|
||||
}
|
||||
}
|
||||
/* 玻璃材质公共类:给页面本地卡片叠加用(与 .tb-card 二选一或组合皆可) */
|
||||
.tb-glass {
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.42));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.55));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
}
|
||||
/* 玻璃加厚:悬浮元素(搜索胶囊 / dock / 弹层)用,透明度更低 */
|
||||
.tb-glass-strong {
|
||||
background: var(--tb-glass-bg-strong, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.tb-glass,
|
||||
.tb-glass-strong {
|
||||
background: var(--tb-card, #ffffff);
|
||||
border-color: var(--tb-card-line, rgba(255, 195, 155, 0.55));
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 卡片质感分层(R2-1 / R2-2)============
|
||||
@@ -142,13 +188,16 @@ page {
|
||||
另有两档专用:tb-elev-primary(主卡·橙)、tb-elev-gold(会员卡·金)。
|
||||
改一处 token,全站卡片同步生效;不新增色系。 */
|
||||
.tb-elev-1 {
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), var(--tb-shadow-1);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4), var(--tb-shadow-1);
|
||||
}
|
||||
.tb-elev-2 {
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), var(--tb-shadow-2);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4), var(--tb-shadow-2);
|
||||
}
|
||||
.tb-elev-3 {
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), var(--tb-shadow-3);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4), var(--tb-shadow-3);
|
||||
}
|
||||
/* 主卡(最常用工具):橙渐变上叠内高光 + 主色投影(对齐既有 .wb-lead 阴影升级) */
|
||||
.tb-elev-primary {
|
||||
@@ -198,10 +247,14 @@ page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
/* 悬浮在橙色 hero 上:玻璃化收益大 */
|
||||
background: var(--tb-glass-bg-strong, rgba(255, 255, 255, 0.68));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
border-radius: 999rpx;
|
||||
padding: 22rpx 30rpx;
|
||||
box-shadow: 0 14rpx 30rpx rgba(151, 60, 8, 0.22);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 14rpx 30rpx rgba(151, 60, 8, 0.18);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -282,7 +335,9 @@ page {
|
||||
border: 2rpx dashed #ffd4b8;
|
||||
border-radius: 30rpx;
|
||||
padding: 56rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: var(--tb-glass-bg-soft, rgba(255, 255, 255, 0.42));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
41
src/api/app.ts
Normal file
41
src/api/app.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { get } from '@/api/request'
|
||||
|
||||
/**
|
||||
* 应用配置接口(对应后端 `api/user/v1/app_config.go`,走 UserAuth 组,需登录态)。
|
||||
* 契约要点(与后端 `g.Meta` 的 path/method **逐字对齐**,改后端务必同步此处):
|
||||
* - `feature_covers` 的 key 对齐本端 `config/features.ts` 的 FEATURE_ENTRIES key;
|
||||
* - 值已是后端按 site.base_url 拼好的绝对 URL,直接用于背景图 / <image>;
|
||||
* - 未配置的 key 不出现在 map 中(调用方做渐变兜底)。
|
||||
*/
|
||||
export interface AppConfigData {
|
||||
feature_covers: Record<string, string>
|
||||
}
|
||||
|
||||
let cache: AppConfigData | null = null
|
||||
let pending: Promise<AppConfigData> | null = null
|
||||
|
||||
/**
|
||||
* 拉取应用配置(进程内缓存:配置变更频率极低,首页多次激活不重复请求;
|
||||
* 失败返回空配置,功能卡走渐变兜底,不打扰用户)。
|
||||
*/
|
||||
export function apiAppConfig(): Promise<AppConfigData> {
|
||||
if (cache)
|
||||
return Promise.resolve(cache)
|
||||
if (!pending) {
|
||||
pending = get<AppConfigData>('/app/config')
|
||||
.then((d) => {
|
||||
cache = d || { feature_covers: {} }
|
||||
return cache
|
||||
})
|
||||
.catch(() => ({ feature_covers: {} } as AppConfigData))
|
||||
.finally(() => {
|
||||
pending = null
|
||||
})
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/** 获取某个功能卡的背景图 URL(未配置返回空串) */
|
||||
export function featureCoverOf(covers: Record<string, string> | undefined, key: string): string {
|
||||
return covers?.[key] || ''
|
||||
}
|
||||
@@ -158,9 +158,18 @@ export function apiMemberCenter() {
|
||||
return get<MemberCenter>('/member/center')
|
||||
}
|
||||
|
||||
/** 客户端平台标识:iOS 走 App Store IAP(pay_channel=apple),服务端据此禁用 API 退款 */
|
||||
export function clientPlatform(): string {
|
||||
try {
|
||||
return uni.getSystemInfoSync().platform || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 建单(第一步):只落一条待支付订单,**不返回支付参数** */
|
||||
export function apiMemberOrderCreate(planKey: string) {
|
||||
return post<MemberOrderCreate>('/member/order', { plan_key: planKey })
|
||||
return post<MemberOrderCreate>('/member/order', { plan_key: planKey, platform: clientPlatform() })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +204,10 @@ export function apiMemberOrderCheck(outTradeNo: string) {
|
||||
return post<MemberOrderCheck>('/member/order/check', { out_trade_no: outTradeNo })
|
||||
}
|
||||
|
||||
/** 我的会员订单(含次数包订单,用 order_type 区分) */
|
||||
export function apiMemberOrders(limit = 20) {
|
||||
return get<{ list: MemberOrder[] }>('/member/orders', { limit })
|
||||
/**
|
||||
* 我的会员订单(含次数包订单,用 order_type 区分)。
|
||||
* tab:0全部 1待付款 2已生效 3退款(后端按 status / refund_status 过滤)。
|
||||
*/
|
||||
export function apiMemberOrders(limit = 20, tab = 0) {
|
||||
return get<{ list: MemberOrder[] }>('/member/orders', { limit, tab: tab || undefined })
|
||||
}
|
||||
|
||||
38
src/api/message.ts
Normal file
38
src/api/message.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/**
|
||||
* 站内信接口封装(对应后端 `api/user/v1/message.go`,UserAuth 组,需登录态)。
|
||||
*
|
||||
* 写入点全在后端业务事件(会员开通/次数包到账、报价回执、报价处理、反馈回复),
|
||||
* C 端只读:列表 / 未读数 / 标记已读。徽标数据源走 `stores/message.ts`。
|
||||
*/
|
||||
export const MSG_TYPE_ORDER_DELIVERED = 'order_delivered'
|
||||
export const MSG_TYPE_QUOTE_SUBMITTED = 'quote_submitted'
|
||||
export const MSG_TYPE_QUOTE_HANDLED = 'quote_handled'
|
||||
export const MSG_TYPE_FEEDBACK_REPLIED = 'feedback_replied'
|
||||
export const MSG_TYPE_SYSTEM = 'system'
|
||||
|
||||
export interface MessageItem {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
link: string
|
||||
is_read: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 我的消息(最近 N 条 + 未读数;不自动置已读) */
|
||||
export function apiMessageList(limit = 20) {
|
||||
return get<{ list: MessageItem[], unread: number }>('/message/list', { limit })
|
||||
}
|
||||
|
||||
/** 未读消息数(首页 / 我的页徽标) */
|
||||
export function apiMessageUnread() {
|
||||
return get<{ count: number }>('/message/unread')
|
||||
}
|
||||
|
||||
/** 标记已读:id>0 单条;id 缺省 = 全部 */
|
||||
export function apiMessageRead(id = 0) {
|
||||
return post<Record<string, never>>('/message/read', id > 0 ? { id } : {})
|
||||
}
|
||||
155
src/api/project.ts
Normal file
155
src/api/project.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/**
|
||||
* 开发项目接口封装(对应后端 `api/user/v1/project.go`,UserAuth 组)。
|
||||
*
|
||||
* 流程:后台建单生成绑定码 → 用户扫码(scene=p<项目id><令牌>)进入绑定页绑定 →
|
||||
* 此后「我的项目」可随时查看阶段进度 / 小节点 / 交付物(原型、UI 设计稿)。
|
||||
*/
|
||||
export const PROJECT_STATUS_RUNNING = 1
|
||||
export const PROJECT_STATUS_DONE = 2
|
||||
export const PROJECT_STATUS_CLOSED = 3
|
||||
|
||||
/** 项目状态 → 主题色 */
|
||||
export function projectStatusTone(status: number): string {
|
||||
switch (status) {
|
||||
case PROJECT_STATUS_DONE: return '#0fa385'
|
||||
case PROJECT_STATUS_CLOSED: return '#9a7b65'
|
||||
default: return '#e8531a'
|
||||
}
|
||||
}
|
||||
|
||||
/** 阶段状态 → 主题色 */
|
||||
export function stageStatusTone(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return '#b57f00'
|
||||
case 2: return '#0fa385'
|
||||
default: return '#9a7b65'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectNode {
|
||||
id: number
|
||||
title: string
|
||||
is_done: number
|
||||
done_at: string
|
||||
}
|
||||
|
||||
export interface ProjectFile {
|
||||
id: number
|
||||
file_name: string
|
||||
file_url: string
|
||||
}
|
||||
|
||||
export interface ProjectStage {
|
||||
id: number
|
||||
title: string
|
||||
status: number
|
||||
status_text: string
|
||||
nodes: ProjectNode[]
|
||||
files: ProjectFile[]
|
||||
}
|
||||
|
||||
export interface ProjectItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
price_cents: number
|
||||
price_note: string
|
||||
due_note: string
|
||||
status: number
|
||||
status_text: string
|
||||
stage_total: number
|
||||
stage_done: number
|
||||
cover_url: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 我的项目列表 */
|
||||
export function apiProjectMine() {
|
||||
return get<{ list: ProjectItem[] }>('/service/project/mine')
|
||||
}
|
||||
|
||||
/** 项目详情(仅限已绑定用户;四期扩展:分期付款 / 维护期 / 验收) */
|
||||
export function apiProjectDetail(id: number) {
|
||||
return get<ProjectDetail>('/service/project/detail', { id })
|
||||
}
|
||||
|
||||
/** 扫码 / 手动输码绑定(绑定成功后服务端会发站内信回执) */
|
||||
export function apiProjectBind(token: string) {
|
||||
return post<{ project_id: number }>('/service/project/bind', { token })
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析小程序码 scene(URL 编码过的 "p<项目id><令牌>")→ 绑定令牌。
|
||||
* 兼容直接传 token 的入口。
|
||||
*/
|
||||
export function parseProjectScene(scene?: string): string {
|
||||
if (!scene)
|
||||
return ''
|
||||
let s = scene
|
||||
try {
|
||||
s = decodeURIComponent(scene)
|
||||
}
|
||||
catch {
|
||||
/* 已是明文则原样使用 */
|
||||
}
|
||||
s = s.trim()
|
||||
if (!s.startsWith('p'))
|
||||
return ''
|
||||
s = s.slice(1)
|
||||
let i = 0
|
||||
while (i < s.length && s[i] >= '0' && s[i] <= '9')
|
||||
i++
|
||||
const token = s.slice(i).trim()
|
||||
return token.length >= 6 && token.length <= 16 ? token : ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 四期:分期付款 / 维护期 / 确认验收
|
||||
// ============================================================================
|
||||
|
||||
/** 分期触发节点 → 展示名(与后端 consts.PayTriggerNameOf 对齐) */
|
||||
export function payTriggerText(trigger: string): string {
|
||||
switch (trigger) {
|
||||
case 'start': return '开工前(定金)'
|
||||
case 'mid_demo': return '中期演示确认后'
|
||||
case 'online_before': return '上线前'
|
||||
case 'online_after': return '上线后'
|
||||
}
|
||||
return trigger
|
||||
}
|
||||
|
||||
export interface ProjectPay {
|
||||
id: number
|
||||
seq: number
|
||||
percent: number
|
||||
amount_cents: number
|
||||
trigger: string
|
||||
trigger_text: string
|
||||
status: number // 0待支付 1已支付
|
||||
paid_at: string
|
||||
}
|
||||
|
||||
export interface ProjectMaintain {
|
||||
plan_name: string
|
||||
free_years: number
|
||||
yearly_fee_cents: number
|
||||
source_only: number
|
||||
accepted: number
|
||||
accepted_at: string
|
||||
start_at: string
|
||||
paid_until: string
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends ProjectItem {
|
||||
stages: ProjectStage[]
|
||||
pays: ProjectPay[]
|
||||
maintain: ProjectMaintain | null
|
||||
can_accept: number
|
||||
}
|
||||
|
||||
/** 确认验收:进入维护期(维护期起点=确认当天,后台可调整) */
|
||||
export function apiProjectAccept(id: number) {
|
||||
return post<{ maintain_start_at: string, paid_until: string }>('/service/project/accept', { id })
|
||||
}
|
||||
@@ -108,7 +108,13 @@ export function apiQuotaPacks(toolKey?: string) {
|
||||
|
||||
/** 建单(第一步):只落一条待支付次数包订单,**不返回支付参数** */
|
||||
export function apiQuotaOrderCreate(packKey: string, toolKey: string) {
|
||||
return post<QuotaOrderCreate>('/quota/order', { pack_key: packKey, tool_key: toolKey })
|
||||
let platform = ''
|
||||
try {
|
||||
platform = uni.getSystemInfoSync().platform || ''
|
||||
} catch {
|
||||
platform = ''
|
||||
}
|
||||
return post<QuotaOrderCreate>('/quota/order', { pack_key: packKey, tool_key: toolKey, platform })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,17 +79,40 @@ export function gotoH5Login() {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 查询参数净化:剥离 undefined / null。
|
||||
*
|
||||
* ⚠️ 跨端差异(真踩过):微信 `wx.request` 对 GET 的 data 对象做**字符串化**序列化,
|
||||
* `undefined` 会变成字面量 `"undefined"` 随请求发出(如 `?keyword=undefined`);
|
||||
* H5 端的 shim 则会跳过。于是「可选参数留空」在 H5 正常、在 MP 变成按
|
||||
* 「undefined」关键词过滤(笔记列表因此恒为空)。统一在此剥离,杜绝该类差异。
|
||||
*/
|
||||
function sanitizeParams(params?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!params)
|
||||
return params
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(params)) {
|
||||
const v = params[k]
|
||||
if (v !== undefined && v !== null)
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function request<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown, opts?: { silent?: boolean }): Promise<T> {
|
||||
const header: Record<string, string> = {}
|
||||
const token = getToken()
|
||||
if (token)
|
||||
header.Authorization = `Bearer ${token}`
|
||||
|
||||
// GET 的 data 会被端侧序列化成 query,先剥掉 undefined/null(见 sanitizeParams 注释)
|
||||
const payload = method === 'GET' ? sanitizeParams(data as Record<string, unknown> | undefined) : data
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
data: data as Record<string, unknown> | undefined,
|
||||
data: payload,
|
||||
header,
|
||||
timeout: 15000,
|
||||
success: (res) => {
|
||||
|
||||
168
src/api/service.ts
Normal file
168
src/api/service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/**
|
||||
* 软件开发服务接口封装(对应后端 `api/user/v1/goods.go`,走 UserAuth 组,需登录态)。
|
||||
* 契约要点(与后端 `g.Meta` 的 path/method **逐字对齐**,改后端务必同步此处):
|
||||
* - 列表/详情均只返回上架(is_enabled=1)商品;
|
||||
* - `cover_url` 已由后端按 settings 的 site.base_url 拼成绝对地址,前端拿到直接用于 <image>;
|
||||
* - `content_md` 为 Markdown 原文,本端用 `utils/md-parser.ts` 解析成 rich-text 节点渲染。
|
||||
*/
|
||||
export const SERVICE_CATEGORY_WEBSITE = 1
|
||||
export const SERVICE_CATEGORY_MINI_PROGRAM = 2
|
||||
export const SERVICE_CATEGORY_APP = 3
|
||||
export const SERVICE_CATEGORY_DESKTOP = 4
|
||||
|
||||
/** 分类筛选项(列表页 tabs;0 = 全部) */
|
||||
export const SERVICE_CATEGORY_TABS = [
|
||||
{ value: 0, label: '全部' },
|
||||
{ value: 1, label: '网站开发' },
|
||||
{ value: 2, label: '小程序开发' },
|
||||
{ value: 3, label: 'App 开发' },
|
||||
{ value: 4, label: '桌面程序开发' },
|
||||
] as const
|
||||
|
||||
export interface ServiceItem {
|
||||
id: number
|
||||
category: number
|
||||
category_name: string
|
||||
title: string
|
||||
summary: string
|
||||
cover_url: string
|
||||
price_cents: number
|
||||
price_note: string
|
||||
}
|
||||
|
||||
export interface ServiceListData {
|
||||
list: ServiceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ServiceDetail extends ServiceItem {
|
||||
content_md: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ServiceListQuery {
|
||||
category?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/** 定价展示:分 → 「¥2999」;0 = 面议(返回空串由调用方兜底) */
|
||||
export function servicePriceText(priceCents: number, priceNote = ''): string {
|
||||
if (!priceCents)
|
||||
return '面议'
|
||||
const yuan = priceCents % 100 === 0 ? String(priceCents / 100) : (priceCents / 100).toFixed(2)
|
||||
return `¥${yuan}${priceNote || ''}`
|
||||
}
|
||||
|
||||
/** 服务列表(仅上架;sort 升序) */
|
||||
export function apiServiceList(query: ServiceListQuery = {}) {
|
||||
return get<ServiceListData>('/goods/list', {
|
||||
category: query.category || undefined,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
/** 服务详情(仅上架;下架/不存在时抛错 message「服务不存在或已下架」) */
|
||||
export function apiServiceDetail(id: number) {
|
||||
return get<ServiceDetail>('/goods/detail', { id })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 服务介绍页 / 作品案例 / 报价申请(迭代-2026-09-30,对应后端 api/user/v1/service.go)
|
||||
// ============================================================================
|
||||
|
||||
/** 介绍页模块(kind 决定渲染版式:hero/rich/image_text/image_list/developer/works/goods) */
|
||||
export interface ServiceBlock {
|
||||
id: number
|
||||
kind: string
|
||||
title: string
|
||||
subtitle: string
|
||||
image_url: string
|
||||
images: string[]
|
||||
content: string
|
||||
}
|
||||
|
||||
/** 服务介绍页模块列表(仅启用,sort 升序;图片为后端拼好的绝对 URL) */
|
||||
export function apiServicePage() {
|
||||
return get<{ list: ServiceBlock[] }>('/service/page')
|
||||
}
|
||||
|
||||
export interface WorkItem {
|
||||
id: number
|
||||
category: number
|
||||
category_name: string
|
||||
title: string
|
||||
summary: string
|
||||
cover_url: string
|
||||
price_cents: number
|
||||
price_note: string
|
||||
}
|
||||
|
||||
export interface WorkDetail extends WorkItem {
|
||||
images: string[]
|
||||
content_md: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 作品列表(仅上架) */
|
||||
export function apiServiceWorks(query: ServiceListQuery = {}) {
|
||||
return get<{ list: WorkItem[], total: number }>('/service/works', {
|
||||
category: query.category || undefined,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
/** 作品详情(仅上架;下架/不存在时抛错) */
|
||||
export function apiServiceWorkDetail(id: number) {
|
||||
return get<WorkDetail>('/service/works/detail', { id })
|
||||
}
|
||||
|
||||
/** 报价单状态 → 主题色(与订单状态色板一致) */
|
||||
export function quoteStatusTone(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return '#b57f00' // 已报价
|
||||
case 2: return '#0fa385' // 已成交
|
||||
case 3: return '#9a7b65' // 已关闭
|
||||
default: return '#e8531a' // 待处理
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuoteSubmitData {
|
||||
work_id?: number
|
||||
contact: string
|
||||
requirement: string
|
||||
budget?: string
|
||||
}
|
||||
|
||||
/** 提交报价申请(成功后服务端会发一条站内信回执) */
|
||||
export function apiQuoteSubmit(data: QuoteSubmitData) {
|
||||
return post<{ id: number }>('/service/quote/submit', {
|
||||
work_id: data.work_id || 0,
|
||||
contact: data.contact,
|
||||
requirement: data.requirement,
|
||||
budget: data.budget || '',
|
||||
})
|
||||
}
|
||||
|
||||
export interface QuoteItem {
|
||||
id: number
|
||||
work_id: number
|
||||
work_title: string
|
||||
requirement: string
|
||||
budget: string
|
||||
status: number
|
||||
status_text: string
|
||||
quote_cents: number
|
||||
quote_note: string
|
||||
handled_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 我的报价申请(最近 N 条,含处理结果) */
|
||||
export function apiQuoteMine(limit = 20) {
|
||||
return get<{ list: QuoteItem[] }>('/service/quote/mine', { limit })
|
||||
}
|
||||
@@ -100,3 +100,24 @@ export function apiWorkbenchSave(toolKeys: string[]) {
|
||||
export function apiFeedbackSubmit(content: string, contact = '') {
|
||||
return post('/feedback/submit', { content, contact })
|
||||
}
|
||||
|
||||
/** 我的反馈条目(含官方回复) */
|
||||
export interface FeedbackMineItem {
|
||||
id: number
|
||||
content: string
|
||||
status: number
|
||||
reply_content: string
|
||||
reply_at: string
|
||||
reply_read: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 我的反馈(最近 N 条 + 未读官方回复数;不会自动置已读) */
|
||||
export function apiFeedbackMine(limit = 20) {
|
||||
return get<{ list: FeedbackMineItem[], unread: number }>('/feedback/mine', { limit })
|
||||
}
|
||||
|
||||
/** 反馈回复标记已读(清红点) */
|
||||
export function apiFeedbackRead() {
|
||||
return post('/feedback/read', {})
|
||||
}
|
||||
|
||||
78
src/components/BackFab.vue
Normal file
78
src/components/BackFab.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 吸顶返回按钮(BackFab,R3)。
|
||||
*
|
||||
* 各子页的导航栏都在页面头部,长列表滚下去后返回按钮跟着滚出视野(用户反馈的
|
||||
* 「返回按钮没有吸顶」)。统一治法:不改动各页导航结构(hero 形态/白卡形态各不相同),
|
||||
* 由本组件在**滚动超过阈值后**浮现一枚页面根级的悬浮返回胶囊——位置固定在
|
||||
* 状态栏下方(MP 用真实状态栏高度,避开右上角胶囊),玻璃材质,不随页面滚动。
|
||||
*
|
||||
* 用法(页面内):
|
||||
* const scrolled = usePageScrolled()
|
||||
* <BackFab :show="scrolled" />
|
||||
*
|
||||
* 层级取 Z.float(1100):盖过内容与吸底工具条(stickyBar 900),低于弹层(sheet 2000)。
|
||||
*/
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
|
||||
withDefaults(defineProps<{ show?: boolean }>(), { show: false })
|
||||
|
||||
const top = navPadTop()
|
||||
|
||||
function goBack() {
|
||||
// 小程序原生返回(保留栈);H5 无栈时退回首页兜底
|
||||
// #ifdef H5
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length <= 1) {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
return
|
||||
}
|
||||
// #endif
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="back-fab press"
|
||||
:class="{ 'back-fab--on': show }"
|
||||
:style="{ top, zIndex: Z.float }"
|
||||
hover-class="press-active"
|
||||
aria-role="button"
|
||||
@tap="goBack"
|
||||
>
|
||||
<wd-icon name="arrow-left" size="34rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 悬浮返回胶囊:页面根级 fixed(不被任何玻璃祖先劫持包含块),
|
||||
平时收起(缩小+透明+不可点),滚过阈值后弹入。 */
|
||||
.back-fab {
|
||||
position: fixed;
|
||||
left: 24rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.9);
|
||||
-webkit-backdrop-filter: blur(20rpx) saturate(1.6);
|
||||
backdrop-filter: blur(20rpx) saturate(1.6);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95), 0 8rpx 22rpx rgba(107, 47, 15, 0.16);
|
||||
opacity: 0;
|
||||
transform: scale(0.5) translateY(-16rpx);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
transform 0.22s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
.back-fab--on {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -99,14 +99,25 @@ function onTap(key: string) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14rpx 28rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
/* 液态玻璃 dock:半透明白 + backdrop 模糊(悬浮在页面内容与氛围光斑之上) */
|
||||
background: var(--tb-glass-bg-strong, rgba(255, 255, 255, 0.68));
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.7);
|
||||
-webkit-backdrop-filter: blur(28rpx) saturate(1.8);
|
||||
backdrop-filter: blur(28rpx) saturate(1.8);
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 18rpx 50rpx rgba(107, 47, 15, 0.18), 0 2rpx 8rpx rgba(107, 47, 15, 0.06);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92), 0 18rpx 50rpx rgba(107, 47, 15, 0.18),
|
||||
0 2rpx 8rpx rgba(107, 47, 15, 0.06);
|
||||
/* 凹槽:在 dock 顶部中央「咬」出一个圆弧缺口,中心键从中凸起
|
||||
(mask 同时裁掉阴影,缺口轮廓会带出阴影形状;不支持 mask 的环境退化为普通胶囊) */
|
||||
-webkit-mask: radial-gradient(circle 80rpx at 50% 0, transparent 96%, #000 100%);
|
||||
mask: radial-gradient(circle 80rpx at 50% 0, transparent 96%, #000 100%);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.tb-tabbar__dock {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
.tb-tabbar__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -498,6 +498,26 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
padding-right: 88rpx;
|
||||
}
|
||||
/* duo 版式 × glass 皮肤:玻璃底替代强渐变(0,3,0 压过 .ct.ct--duo 的 0,2,0),
|
||||
白字随之换深字,保证半透明底上的对比度;外阴影同样中性(不用彩色光晕) */
|
||||
.ct.ct--duo.ct--skin-glass {
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.42));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.55));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
box-shadow:
|
||||
inset 0 0 0 1rpx rgba(255, 255, 255, 0.4),
|
||||
inset 0 2rpx 10rpx rgba(255, 255, 255, 0.8),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4),
|
||||
0 2rpx 8rpx rgba(80, 45, 20, 0.06),
|
||||
0 16rpx 40rpx rgba(80, 45, 20, 0.12);
|
||||
}
|
||||
.ct.ct--duo.ct--skin-glass .ct__title {
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.ct.ct--duo.ct--skin-glass .ct__desc {
|
||||
color: #6b4f3a;
|
||||
}
|
||||
.ct__art--duo {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
@@ -506,12 +526,15 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
|
||||
白底容器 + 区块标题 + 右上「更多」角标 + 子卡网格(子卡由默认插槽渲染)。
|
||||
特异性 0,2,0(.ct.ct--group)> 皮肤 0,1,0 → 恒为白底,不被主题皮肤染色。 */
|
||||
.ct.ct--group {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(255, 195, 155, 0.55);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.42));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.55));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
/* 容器尺寸基准也走变量:与尺寸档同特异度时靠**源序**(尺寸档在后)让窄列尺寸档胜出(R-2) */
|
||||
--ct-radius: 34rpx;
|
||||
--ct-pad: 26rpx 24rpx 28rpx;
|
||||
box-shadow: 0 3rpx 10rpx rgba(107, 47, 15, 0.05), 0 16rpx 38rpx rgba(107, 47, 15, 0.1);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92), inset 0 -1rpx 0 rgba(255, 255, 255, 0.4),
|
||||
0 16rpx 40rpx rgba(80, 45, 20, 0.12);
|
||||
gap: 20rpx;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -534,9 +557,10 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 999rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
-webkit-backdrop-filter: blur(6rpx);
|
||||
backdrop-filter: blur(6rpx);
|
||||
}
|
||||
.ct__headtitle {
|
||||
@@ -620,14 +644,21 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
|
||||
var(--ct-tint);
|
||||
box-shadow: 0 10rpx 24rpx var(--ct-shadow);
|
||||
}
|
||||
/* glass:半透明 + 1rpx 白描边 + 内高光 */
|
||||
/* glass:液态玻璃——半透明白 + backdrop blur/saturate + 玻璃环高光。
|
||||
外阴影只用中性暖棕(**不用 var(--ct-shadow) 彩色光晕**:那是「AI 渐变光」的来源,
|
||||
Apple 玻璃的立体感来自中性阴影 + 顶部高光 + 底部折射亮边)。
|
||||
token 与 App.vue 的 --tb-glass-* 同源(page 上定义,跨组件继承;带兜底值)。 */
|
||||
.ct--skin-glass {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.9);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.42));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.55));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 36rpx)) saturate(var(--tb-glass-saturate, 1.9));
|
||||
box-shadow:
|
||||
inset 0 2rpx 8rpx rgba(255, 255, 255, 0.85),
|
||||
0 8rpx 22rpx rgba(107, 47, 15, 0.1);
|
||||
backdrop-filter: blur(10rpx);
|
||||
inset 0 0 0 1rpx rgba(255, 255, 255, 0.4),
|
||||
inset 0 2rpx 10rpx rgba(255, 255, 255, 0.8),
|
||||
inset 0 -1rpx 0 rgba(255, 255, 255, 0.4),
|
||||
0 2rpx 8rpx rgba(80, 45, 20, 0.06),
|
||||
0 16rpx 40rpx rgba(80, 45, 20, 0.12);
|
||||
}
|
||||
/* solid:饱和渐变 + 釉面高光 + 彩色投影(不是灰黑) */
|
||||
.ct--skin-solid {
|
||||
|
||||
@@ -15,6 +15,9 @@ import CountUp from '@/components/CountUp.vue'
|
||||
import PaneRefresher from '@/components/home/PaneRefresher.vue'
|
||||
import Sticker from '@/components/Sticker.vue'
|
||||
import { FEATURE_ENTRIES } from '@/config/features'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { presetOf } from '@/utils/avatar'
|
||||
@@ -26,6 +29,9 @@ const emit = defineEmits<{ feedback: [], bind: [], cardstyle: [] }>()
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
const userStore = useUserStore()
|
||||
const feedbackStore = useFeedbackStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
/** 「我的」页菜单行图标块配色(回滚:还原改造前的 CELL_BG/CELL_COLOR 语义) */
|
||||
const CELL_ICON = {
|
||||
@@ -49,13 +55,19 @@ const heroPad = heroPadTop()
|
||||
|
||||
let loadedOnce = false
|
||||
|
||||
/** 我的域刷新:登录态 + 等级 + 工具表(**不含额度**) */
|
||||
/** 我的域刷新:登录态 + 等级 + 工具表(**不含额度**)+ 反馈未读数(红点) */
|
||||
async function load() {
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
await Promise.all([
|
||||
toolsStore.fetchAll().catch(() => {}),
|
||||
userStore.refreshLevel().catch(() => {}),
|
||||
// 会员用户卡需要 is_member 标记(/quota/my 很轻,失败静默走非会员卡片);
|
||||
// 站内信未读数(金刚区徽标)同样仅登录后拉
|
||||
...(userStore.logged ? [quotaStore.fetchMine().catch(() => {})] : []),
|
||||
...(userStore.logged ? [messageStore.fetchUnread()] : []),
|
||||
// 已登录才拉反馈未读(未登录时接口 401,会触发全局 toast)
|
||||
...(userStore.logged ? [feedbackStore.fetchMine().catch(() => {})] : []),
|
||||
])
|
||||
}
|
||||
catch {
|
||||
@@ -77,9 +89,16 @@ watch(() => props.active, (v) => {
|
||||
defineExpose({ refresh })
|
||||
|
||||
const levelName = computed(() => userStore.level?.name || '未授权')
|
||||
/** 是否会员(quota 总览的 is_member 标记,工作台/我的任一 pane 加载后即有值) */
|
||||
const isMember = computed(() => quotaStore.isMember)
|
||||
/** 已解锁的大类名(toolsStore.modules 即大类,锁定态过滤) */
|
||||
const unlockedModules = computed(() =>
|
||||
toolsStore.modules.filter(m => !m.locked).map(m => m.name),
|
||||
)
|
||||
/** 已解锁大类一行文案(R6:不再逐个 chip 铺开,合并成一行更清爽) */
|
||||
const unlockedText = computed(() =>
|
||||
unlockedModules.value.length ? unlockedModules.value.join(' · ') : '暂无已解锁大类',
|
||||
)
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: '工作台工具', value: toolsStore.workbenchKeys.length },
|
||||
@@ -164,16 +183,54 @@ function goMember() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
/** 我的订单 */
|
||||
function goOrders() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/orders' })
|
||||
/** 我的订单(金刚区 / 订单入库统一入口,tab: 0全部 1待付款 2已生效 3退款) */
|
||||
function goOrders(tab = 0) {
|
||||
uni.navigateTo({ url: `/pkg-member/member/orders?tab=${tab}` })
|
||||
}
|
||||
|
||||
/** 我的优惠券 */
|
||||
/** 我的优惠券(金刚区) */
|
||||
function goCoupons() {
|
||||
uni.navigateTo({ url: '/pkg-member/coupon/index' })
|
||||
}
|
||||
|
||||
/** 我的报价(软件开发服务域) */
|
||||
function goQuotes() {
|
||||
uni.navigateTo({ url: '/pkg-service/quotes' })
|
||||
}
|
||||
|
||||
/** 消息中心(站内信) */
|
||||
function goMessages() {
|
||||
uni.navigateTo({ url: '/pkg-msg/index' })
|
||||
}
|
||||
|
||||
/** 我的项目(开发项目进度跟踪) */
|
||||
function goProjects() {
|
||||
uni.navigateTo({ url: '/pkg-service/projects' })
|
||||
}
|
||||
|
||||
/** 服务与价格列表 */
|
||||
function goGoodsList() {
|
||||
uni.navigateTo({ url: '/pkg-service/list' })
|
||||
}
|
||||
|
||||
/** 订单入库四态(R4):与订单页 tab 一一对应 */
|
||||
const ORDER_TABS = [
|
||||
{ tab: 0, label: '全部', icon: 'list', bg: '#FFE8DC', color: '#E8531A' },
|
||||
{ tab: 1, label: '待付款', icon: 'clock', bg: '#FFF4D6', color: '#B57F00' },
|
||||
{ tab: 2, label: '已生效', icon: 'check-circle', bg: '#DFF7F0', color: '#0FA385' },
|
||||
{ tab: 3, label: '退款', icon: 'rollback', bg: '#E3F2FF', color: '#1677D9' },
|
||||
] as const
|
||||
|
||||
/** 金刚区宫格(R2 + 三期):消息中心带未读徽标;第二行放项目与服务价格 */
|
||||
const QUICK_ENTRIES = computed(() => [
|
||||
{ key: 'messages', label: '消息中心', icon: 'notification', bg: '#FFF1E8', color: '#E8531A', action: goMessages, badge: messageStore.unread },
|
||||
{ key: 'quotes', label: '我的报价', icon: 'edit-outline', bg: '#DFF7F0', color: '#0FA385', action: goQuotes, badge: 0 },
|
||||
{ key: 'projects', label: '我的项目', icon: 'app', bg: '#ECE2FF', color: '#5B2E9E', action: goProjects, badge: 0 },
|
||||
{ key: 'orders', label: '我的订单', icon: 'list', bg: '#E3F2FF', color: '#1677D9', action: () => goOrders(0), badge: 0 },
|
||||
{ key: 'coupons', label: '优惠券', icon: 'discount-filled', bg: '#FFF4D6', color: '#B57F00', action: goCoupons, badge: 0 },
|
||||
{ key: 'goods', label: '服务价格', icon: 'computer', bg: '#FFE8DC', color: '#E8531A', action: goGoodsList, badge: 0 },
|
||||
])
|
||||
|
||||
function logout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
@@ -206,27 +263,32 @@ function logout() {
|
||||
|
||||
<!-- 渐变头区 + 用户卡 -->
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="user tb-rise press" hover-class="press-active" @tap="goProfile">
|
||||
<Sticker type="sparkle" :size="60" float :delay="500" class="user__sticker" />
|
||||
<view class="user tb-rise press" :class="{ 'user--vip': isMember }" hover-class="press-active" @tap="goProfile">
|
||||
<!-- 会员:金卡徽标;非会员:保持贴纸点缀 -->
|
||||
<Sticker v-if="!isMember" type="sparkle" :size="60" float :delay="500" class="user__sticker" />
|
||||
<view v-else class="user__vip-badge">
|
||||
VIP
|
||||
</view>
|
||||
<view class="user__avatar" :style="{ background: avatarBg }">
|
||||
<Sticker v-if="avatarPreset" :type="avatarPreset.sticker" :size="64" />
|
||||
<image v-else-if="userStore.user?.avatar_url" :src="userStore.user.avatar_url" class="user__img" />
|
||||
<wd-icon v-else name="user" size="56rpx" color="#FF6B35" />
|
||||
<wd-icon v-else name="user" size="56rpx" :color="isMember ? '#F5C56B' : '#FF6B35'" />
|
||||
</view>
|
||||
<view class="user__body">
|
||||
<text class="user__name">
|
||||
<text class="user__name" :class="{ 'user__name--vip': isMember }">
|
||||
{{ displayName }}
|
||||
</text>
|
||||
<view class="user__tags">
|
||||
<view class="user__level">
|
||||
{{ levelName }}
|
||||
</view>
|
||||
<view v-for="m in unlockedModules" :key="m" class="user__module">
|
||||
{{ m }}
|
||||
<view class="user__level" :class="{ 'user__level--vip': isMember }">
|
||||
{{ isMember ? `${levelName}会员` : levelName }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 已解锁功能只写大类(一行合并,不再逐个铺 chip) -->
|
||||
<text class="user__unlocked" :class="{ 'user__unlocked--vip': isMember }">
|
||||
{{ unlockedText }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-icon v-if="userStore.logged" name="arrow-right" size="30rpx" color="#D9C3B2" class="user__go" />
|
||||
<wd-icon v-if="userStore.logged" name="arrow-right" size="30rpx" :color="isMember ? 'rgba(245,197,107,0.8)' : '#D9C3B2'" class="user__go" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -242,8 +304,58 @@ function logout() {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单(回滚:单张白卡 + 行列表) -->
|
||||
<view class="tb-card cells tb-rise" style="animation-delay: 60ms">
|
||||
<!-- 金刚区(宫格):消息中心(未读徽标)/ 我的报价 / 优惠券 / 订单 -->
|
||||
<view class="tb-card quick tb-rise" style="animation-delay: 30ms">
|
||||
<view
|
||||
v-for="q in QUICK_ENTRIES"
|
||||
:key="q.key"
|
||||
class="quick__item press"
|
||||
hover-class="press-active"
|
||||
@tap="q.action()"
|
||||
>
|
||||
<view class="quick__icon" :style="{ background: q.bg }">
|
||||
<wd-icon :name="q.icon" size="36rpx" :color="q.color" />
|
||||
<view v-if="q.badge > 0" class="quick__badge">
|
||||
{{ q.badge > 99 ? '99+' : q.badge }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="quick__label">
|
||||
{{ q.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单入库(R4):四态直达,替代原菜单里的「我的订单」行 -->
|
||||
<view class="tb-card orderq tb-rise" style="animation-delay: 50ms">
|
||||
<view class="orderq__head">
|
||||
<text class="tb-section-title">
|
||||
我的订单
|
||||
</text>
|
||||
<view class="tb-section-more press" hover-class="press-active" @tap="goOrders(0)">
|
||||
<text>全部订单</text>
|
||||
<wd-icon name="arrow-right" size="26rpx" color="#FF6B35" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="orderq__row">
|
||||
<view
|
||||
v-for="t in ORDER_TABS"
|
||||
:key="t.tab"
|
||||
class="orderq__item press"
|
||||
hover-class="press-active"
|
||||
@tap="goOrders(t.tab)"
|
||||
>
|
||||
<view class="orderq__icon" :style="{ background: t.bg }">
|
||||
<wd-icon :name="t.icon" size="34rpx" :color="t.color" />
|
||||
</view>
|
||||
<text class="orderq__label">
|
||||
{{ t.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单(回滚:单张白卡 + 行列表);订单/优惠券入口已上移到金刚区与订单入库 -->
|
||||
<view class="tb-card cells tb-rise" style="animation-delay: 70ms">
|
||||
<view class="cell press" hover-class="press-active" @tap="goEditWorkbench">
|
||||
<view class="cell__icon" :style="{ background: CELL_ICON.workbench.bg }">
|
||||
<wd-icon name="edit-outline" size="34rpx" :color="CELL_ICON.workbench.color" />
|
||||
@@ -277,32 +389,6 @@ function logout() {
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
|
||||
<view class="cell press" hover-class="press-active" @tap="goOrders">
|
||||
<view class="cell__icon" :style="{ background: CELL_ICON.orders.bg }">
|
||||
<wd-icon name="list" size="34rpx" :color="CELL_ICON.orders.color" />
|
||||
</view>
|
||||
<text class="cell__label">
|
||||
我的订单
|
||||
</text>
|
||||
<text class="cell__value">
|
||||
查看购买记录
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
|
||||
<view class="cell press" hover-class="press-active" @tap="goCoupons">
|
||||
<view class="cell__icon" :style="{ background: CELL_ICON.coupons.bg }">
|
||||
<wd-icon name="discount-filled" size="34rpx" :color="CELL_ICON.coupons.color" />
|
||||
</view>
|
||||
<text class="cell__label">
|
||||
我的优惠券
|
||||
</text>
|
||||
<text class="cell__value">
|
||||
免单券可抵扣
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
|
||||
<!-- 卡片风格:只配置「首页」卡片,本页自身不用卡片风格 -->
|
||||
<view class="cell press" hover-class="press-active" @tap="emit('cardstyle')">
|
||||
<view class="cell__icon" :style="{ background: CELL_ICON.cardstyle.bg }">
|
||||
@@ -339,6 +425,10 @@ function logout() {
|
||||
<text class="cell__label">
|
||||
意见反馈
|
||||
</text>
|
||||
<text v-if="feedbackStore.unread > 0" class="cell__value cell__value--dot">
|
||||
{{ feedbackStore.unread }} 条新回复
|
||||
</text>
|
||||
<view v-if="feedbackStore.unread > 0" class="cell__red-dot" />
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
|
||||
@@ -398,12 +488,34 @@ function logout() {
|
||||
padding: 32rpx 28rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(151, 60, 8, 0.28);
|
||||
}
|
||||
/* 会员特殊用户卡:深炭金卡(与会员中心/金币卡同一套语言),半透明深色玻璃 */
|
||||
.user--vip {
|
||||
background: linear-gradient(150deg, rgba(255, 233, 184, 0.2) 0%, rgba(255, 255, 255, 0.05) 55%), linear-gradient(160deg, #2b1a10 0%, #4a2c16 55%, #2b1a10 100%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.28), 0 18rpx 44rpx rgba(20, 10, 4, 0.4);
|
||||
}
|
||||
.user__sticker {
|
||||
position: absolute;
|
||||
right: 16rpx;
|
||||
top: 12rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* 会员右上角 VIP 徽标(替代贴纸位) */
|
||||
.user__vip-badge {
|
||||
position: absolute;
|
||||
right: 24rpx;
|
||||
top: 20rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ffe9b8, #ffd77a);
|
||||
color: #8a5a00;
|
||||
font-size: 20rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
box-shadow: 0 6rpx 14rpx rgba(180, 127, 0, 0.3);
|
||||
}
|
||||
.user__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 112rpx;
|
||||
@@ -416,21 +528,35 @@ function logout() {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10rpx 22rpx rgba(107, 47, 15, 0.14);
|
||||
}
|
||||
.user--vip .user__avatar {
|
||||
border-color: rgba(245, 197, 107, 0.75);
|
||||
}
|
||||
.user__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.user__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.user__name {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user__name--vip {
|
||||
color: #ffe9b8;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.user__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.user__level {
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
@@ -441,13 +567,26 @@ function logout() {
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.3);
|
||||
}
|
||||
.user__module {
|
||||
background: #fff1e8;
|
||||
border: 2rpx solid #ffd4b8;
|
||||
color: #e8531a;
|
||||
.user__level--vip {
|
||||
background: linear-gradient(140deg, #ffe9b8, #f5c56b);
|
||||
color: #6b4400;
|
||||
box-shadow: 0 6rpx 14rpx rgba(180, 127, 0, 0.28);
|
||||
}
|
||||
/* 已解锁大类:合并为一行(R6) */
|
||||
.user__unlocked {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
line-height: 1.5;
|
||||
color: #9a7b65;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.user__unlocked--vip {
|
||||
color: rgba(255, 233, 184, 0.78);
|
||||
}
|
||||
|
||||
/* 数据总览(回滚:白卡内的三段统计) */
|
||||
@@ -476,11 +615,96 @@ function logout() {
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
/* 金刚区(宫格):四列图标入口 */
|
||||
.quick {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.quick__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.quick__icon {
|
||||
position: relative;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), 0 6rpx 14rpx rgba(107, 47, 15, 0.1);
|
||||
}
|
||||
.quick__badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -10rpx;
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ff4d1f;
|
||||
border: 2rpx solid #ffffff;
|
||||
color: #ffffff;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
line-height: 26rpx;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.quick__label {
|
||||
font-size: 23rpx;
|
||||
color: #3d2a1e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 订单入库(四态直达) */
|
||||
.orderq {
|
||||
padding: 28rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.orderq__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.orderq__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18rpx;
|
||||
}
|
||||
.orderq__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.orderq__icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), 0 6rpx 14rpx rgba(107, 47, 15, 0.1);
|
||||
}
|
||||
.orderq__label {
|
||||
font-size: 23rpx;
|
||||
color: #3d2a1e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 菜单(回滚:白卡 + 行列表,无卡片风格) */
|
||||
.cells {
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
@@ -517,6 +741,22 @@ function logout() {
|
||||
color: #9a7b65;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
/* 意见反馈:新回复提醒(N 条新回复 + 红点) */
|
||||
.cell__value--dot {
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell__red-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -34rpx;
|
||||
right: 44rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #ff4d1f;
|
||||
box-shadow: 0 0 0 4rpx rgba(255, 107, 53, 0.18);
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-top: 40rpx;
|
||||
|
||||
@@ -7,14 +7,19 @@
|
||||
* ——工作台需要展示额度,这是三 pane 里唯一会打 /quota/my 的域。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { apiAppConfig } from '@/api/app'
|
||||
import { apiToolsUsed } from '@/api/user'
|
||||
import CardTile from '@/components/card/CardTile.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import PaneRefresher from '@/components/home/PaneRefresher.vue'
|
||||
import VipCard from '@/components/vip/VipCard.vue'
|
||||
import { FEATURE_ENTRIES } from '@/config/features'
|
||||
import type { ProjectItem } from '@/api/project'
|
||||
import { apiProjectMine } from '@/api/project'
|
||||
import { CARD_TONES } from '@/config/card-styles'
|
||||
import { FEATURE_ENTRIES, type FeatureEntry } from '@/config/features'
|
||||
import { TOOL_REGISTRY } from '@/config/tools'
|
||||
import { useCardStyleStore } from '@/stores/cardStyle'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -30,6 +35,7 @@ const toolsStore = useToolsStore()
|
||||
const userStore = useUserStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const cardStyle = useCardStyleStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
const { refreshing, bubbleScale, onPulling, onRestore, run } = usePaneRefresh()
|
||||
|
||||
@@ -40,12 +46,15 @@ const heroPad = heroPadTop()
|
||||
const ready = ref(false)
|
||||
let loadedOnce = false
|
||||
|
||||
/** 工作台域刷新:工具表 + 工作台 + 额度(额度为工作台独有) */
|
||||
/** 工作台域刷新:工具表 + 工作台 + 额度(额度为工作台独有)+ 功能卡背景图(fire-and-forget) */
|
||||
async function load() {
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
await toolsStore.fetchAll()
|
||||
await quotaStore.fetchMine().catch(() => {})
|
||||
// 站内信未读数(首页消息入口徽标):登录后才拉,失败静默
|
||||
if (userStore.logged)
|
||||
messageStore.fetchUnread()
|
||||
}
|
||||
catch {
|
||||
/* 静默:展示空态 */
|
||||
@@ -53,6 +62,30 @@ async function load() {
|
||||
finally {
|
||||
ready.value = true
|
||||
}
|
||||
// 功能卡背景图:后台系统配置下发(/app/config,api/app.ts 内有进程缓存),
|
||||
// 失败静默走色系渐变兜底,不打扰首页
|
||||
apiAppConfig().then((d) => {
|
||||
covers.value = d.feature_covers || {}
|
||||
}).catch(() => {})
|
||||
// 进行中项目快捷入口(fire-and-forget,失败静默)
|
||||
pickActiveProject()
|
||||
}
|
||||
|
||||
/** 进行中的开发项目(首页快捷入口):取「我的项目」里第一个进行中的 */
|
||||
const activeProject = ref<ProjectItem | null>(null)
|
||||
|
||||
function pickActiveProject() {
|
||||
if (!userStore.logged) {
|
||||
activeProject.value = null
|
||||
return
|
||||
}
|
||||
apiProjectMine()
|
||||
.then((res) => {
|
||||
activeProject.value = (res.list || []).find(p => p.status === 1) || null
|
||||
})
|
||||
.catch(() => {
|
||||
/* 静默:入口下次刷新再出 */
|
||||
})
|
||||
}
|
||||
|
||||
/** 供首页 onShow 调用 */
|
||||
@@ -107,6 +140,19 @@ function goEdit() {
|
||||
uni.navigateTo({ url: '/pages/workbench/edit' })
|
||||
}
|
||||
|
||||
/** 消息中心(站内信):首页消息入口 */
|
||||
function goMessages() {
|
||||
uni.navigateTo({ url: '/pkg-msg/index' })
|
||||
}
|
||||
|
||||
/** 进行中项目详情 */
|
||||
function goProject(id: number) {
|
||||
uni.navigateTo({ url: `/pkg-service/project-detail?id=${id}` })
|
||||
}
|
||||
|
||||
/** 徽标数字:超过 99 显示 99+ */
|
||||
const messageBadge = computed(() => (messageStore.unread > 99 ? '99+' : messageStore.unread > 0 ? String(messageStore.unread) : ''))
|
||||
|
||||
/**
|
||||
* 功能入口(笔记 / 年会查座)跳转:入口来源 `config/features.ts`,**不走** TOOL_REGISTRY
|
||||
* (后者会被后端 tools 表判 locked 而静默锁死,见 features.ts 顶部注释)。
|
||||
@@ -116,6 +162,27 @@ function goFeature(path: string) {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
// ===== 便捷功能自适应布局(迭代-2026-09-30)=====
|
||||
// ≤2 张:双列 CardTile(原排版);=3 张:「1 大 2 小」拼图(图2 参考样式,主题化);
|
||||
// ≥4 张:横滑图片卡(图3 参考样式)。背景图来自后台「系统配置 → 首页功能卡」
|
||||
//(settings home.feature.covers,经 /app/config 下发绝对 URL),未配置用色系渐变兜底。
|
||||
const covers = ref<Record<string, string>>({})
|
||||
|
||||
const featsMode = computed(() =>
|
||||
FEATURE_ENTRIES.length >= 4 ? 'scroll' : FEATURE_ENTRIES.length === 3 ? 'mosaic' : 'grid',
|
||||
)
|
||||
|
||||
/** 功能卡背景图(绝对 URL;空 = 未配置) */
|
||||
function coverOf(fe: FeatureEntry): string {
|
||||
return covers.value[fe.key] || ''
|
||||
}
|
||||
|
||||
/** 未配置背景图时的色系渐变兜底(配色铁律:只动质感不动色相) */
|
||||
function fallbackStyle(fe: FeatureEntry): string {
|
||||
const tone = CARD_TONES.find(t => t.id === fe.tone) || CARD_TONES[0]
|
||||
return `background: linear-gradient(140deg, ${tone.gradFrom}, ${tone.gradTo})`
|
||||
}
|
||||
|
||||
const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
|
||||
const HOT_STICKERS = ['sparkle', 'bolt', 'cloud', 'star', 'rocket', 'rainbow'] as const
|
||||
|
||||
@@ -180,6 +247,13 @@ function leadCard(key: string) {
|
||||
<view v-if="userStore.level" class="hero__level">
|
||||
{{ userStore.level.name }}
|
||||
</view>
|
||||
<!-- 消息入口:铃铛 + 未读徽标(站内信) -->
|
||||
<view class="hero__msg press" hover-class="press-active" @tap="goMessages">
|
||||
<wd-icon name="notification" size="34rpx" color="#ffffff" />
|
||||
<view v-if="messageBadge" class="hero__msg-badge">
|
||||
{{ messageBadge }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hero__title">
|
||||
工具百宝箱
|
||||
@@ -199,15 +273,68 @@ function leadCard(key: string) {
|
||||
<VipCard />
|
||||
</view>
|
||||
|
||||
<!-- 便捷功能(笔记 / 年会查座):入口独立于工具库楼层(P1-2),来源 config/features.ts。
|
||||
R5:整块上移到「我的工作台」之上(用户诉求)——进页面先看到两个功能入口。 -->
|
||||
<view class="tb-card sec sec--gap tb-rise" style="animation-delay: 90ms">
|
||||
<!-- 进行中的开发项目:快捷入口(封面图 + 进度徽标),仅在有进行中项目时出现 -->
|
||||
<view
|
||||
v-if="activeProject"
|
||||
class="proj tb-card tb-elev-2 press tb-rise"
|
||||
style="animation-delay: 60ms"
|
||||
hover-class="press-active"
|
||||
@tap="goProject(activeProject.id)"
|
||||
>
|
||||
<view class="proj__cover">
|
||||
<image
|
||||
v-if="activeProject.cover_url"
|
||||
class="proj__img"
|
||||
:src="activeProject.cover_url"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="proj__img proj__img--ph">
|
||||
<wd-icon name="app" size="52rpx" color="#E8A87C" />
|
||||
</view>
|
||||
<view class="proj__badge">
|
||||
{{ activeProject.stage_done }}/{{ activeProject.stage_total }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="proj__body">
|
||||
<view class="proj__top">
|
||||
<text class="proj__title">
|
||||
{{ activeProject.title }}
|
||||
</text>
|
||||
<view class="proj__status">
|
||||
进行中
|
||||
</view>
|
||||
</view>
|
||||
<view class="proj__progress">
|
||||
<view class="proj__track">
|
||||
<view
|
||||
class="proj__fill"
|
||||
:style="{ width: `${activeProject.stage_total ? Math.round((activeProject.stage_done / activeProject.stage_total) * 100) : 0}%` }"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="proj__meta">
|
||||
<text class="proj__stage">
|
||||
阶段进度 {{ activeProject.stage_done }}/{{ activeProject.stage_total }}
|
||||
</text>
|
||||
<text v-if="activeProject.due_note" class="proj__due">
|
||||
交付周期 {{ activeProject.due_note }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 便捷功能(笔记 / 年会查座 / 软件开发):入口独立于工具库楼层(P1-2),来源 config/features.ts。
|
||||
无玻璃外壳(大图卡下玻璃底显得脏,用户点名):标题行直接压页面底色,图卡自带投影。
|
||||
布局随卡片数量自适应:≤2 双列 CardTile / =3 拼图 / ≥4 横滑(背景图后台可配)。 -->
|
||||
<view class="sec sec--flat sec--gap tb-rise" style="animation-delay: 90ms">
|
||||
<view class="sec__head">
|
||||
<text class="tb-section-title">
|
||||
便捷功能
|
||||
</text>
|
||||
</view>
|
||||
<view class="feats">
|
||||
|
||||
<!-- ≤2 张:双列 CardTile(原排版) -->
|
||||
<view v-if="featsMode === 'grid'" class="feats">
|
||||
<view
|
||||
v-for="fe in FEATURE_ENTRIES"
|
||||
:key="fe.key"
|
||||
@@ -225,6 +352,83 @@ function leadCard(key: string) {
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- =3 张:「1 大 2 小」拼图(主题化图2) -->
|
||||
<view v-else-if="featsMode === 'mosaic'" class="fm">
|
||||
<view
|
||||
class="fm__main press"
|
||||
hover-class="press-active"
|
||||
@tap="goFeature(FEATURE_ENTRIES[0].path)"
|
||||
>
|
||||
<image
|
||||
v-if="coverOf(FEATURE_ENTRIES[0])"
|
||||
class="fm__bg"
|
||||
:src="coverOf(FEATURE_ENTRIES[0])"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="fm__bg" :style="fallbackStyle(FEATURE_ENTRIES[0])" />
|
||||
<view class="fm__shade" />
|
||||
<view class="fm__body">
|
||||
<text class="fm__title">
|
||||
{{ FEATURE_ENTRIES[0].name }}
|
||||
</text>
|
||||
<view class="fm__pill">
|
||||
<text class="fm__pill-text">
|
||||
{{ FEATURE_ENTRIES[0].desc }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="20rpx" color="#E8531A" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="fm__side">
|
||||
<view
|
||||
v-for="fe in FEATURE_ENTRIES.slice(1)"
|
||||
:key="fe.key"
|
||||
class="fm__sub press"
|
||||
hover-class="press-active"
|
||||
@tap="goFeature(fe.path)"
|
||||
>
|
||||
<image v-if="coverOf(fe)" class="fm__bg" :src="coverOf(fe)" mode="aspectFill" />
|
||||
<view v-else class="fm__bg" :style="fallbackStyle(fe)" />
|
||||
<view class="fm__shade" />
|
||||
<view class="fm__body">
|
||||
<text class="fm__title fm__title--sm">
|
||||
{{ fe.name }}
|
||||
</text>
|
||||
<view class="fm__pill">
|
||||
<text class="fm__pill-text">
|
||||
{{ fe.desc }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="18rpx" color="#E8531A" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ≥4 张:横滑图片卡(主题化图3) -->
|
||||
<scroll-view v-else scroll-x class="fs" :show-scrollbar="false">
|
||||
<view class="fs__row">
|
||||
<view
|
||||
v-for="fe in FEATURE_ENTRIES"
|
||||
:key="fe.key"
|
||||
class="fs__card press"
|
||||
hover-class="press-active"
|
||||
@tap="goFeature(fe.path)"
|
||||
>
|
||||
<view class="fs__imgwrap">
|
||||
<image v-if="coverOf(fe)" class="fs__img" :src="coverOf(fe)" mode="aspectFill" />
|
||||
<view v-else class="fs__img" :style="fallbackStyle(fe)" />
|
||||
</view>
|
||||
<text class="fs__title">
|
||||
{{ fe.name }}
|
||||
</text>
|
||||
<text class="fs__desc">
|
||||
{{ fe.desc }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 我的工作台 -->
|
||||
@@ -365,6 +569,36 @@ function leadCard(key: string) {
|
||||
padding: 4rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
/* 消息入口:白色玻璃圆钮 + 未读徽标 */
|
||||
.hero__msg {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero__msg-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -10rpx;
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ff4d1f;
|
||||
border: 2rpx solid #ffffff;
|
||||
color: #ffffff;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
line-height: 26rpx;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hero__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
@@ -378,12 +612,120 @@ function leadCard(key: string) {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
/* 进行中项目快捷入口 */
|
||||
.proj {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
padding: 22rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.proj__cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 190rpx;
|
||||
height: 150rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
background: #fff3e9;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
}
|
||||
.proj__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 同上:图片自带圆角,不依赖容器裁切 */
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
.proj__img--ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.proj__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 0 20rpx 0 18rpx;
|
||||
background: linear-gradient(140deg, #ff7a3d, #fb4e14);
|
||||
color: #ffffff;
|
||||
font-size: 19rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 4rpx 10rpx rgba(251, 78, 20, 0.3);
|
||||
}
|
||||
.proj__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.proj__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.proj__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.proj__status {
|
||||
flex-shrink: 0;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffd4b8;
|
||||
color: #e8531a;
|
||||
font-size: 19rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.proj__progress {
|
||||
display: flex;
|
||||
}
|
||||
.proj__track {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffe3d3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.proj__fill {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #ff9f4d, #fb4e14);
|
||||
}
|
||||
.proj__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.proj__stage {
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.proj__due {
|
||||
font-size: 20rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.sec {
|
||||
padding: 30rpx 28rpx 32rpx;
|
||||
}
|
||||
.sec--gap {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
/* frameless 便捷功能(用户点名:玻璃壳在大图卡下显脏):去壳后贴齐页面栅格 */
|
||||
.sec--flat {
|
||||
padding: 8rpx 0 0;
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -427,7 +769,7 @@ function leadCard(key: string) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 便捷功能:两列 */
|
||||
/* 便捷功能:两列(≤2 张时的原排版) */
|
||||
.feats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -435,6 +777,144 @@ function leadCard(key: string) {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
/* 便捷功能:=3 张的「1 大 2 小」拼图(参考图2,主题化) */
|
||||
.fm {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr;
|
||||
gap: 16rpx;
|
||||
height: 380rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.fm__main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 26rpx;
|
||||
background: #fff3e9;
|
||||
box-shadow: 0 10rpx 24rpx rgba(151, 60, 8, 0.16);
|
||||
}
|
||||
.fm__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
.fm__sub {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 26rpx;
|
||||
background: #fff3e9;
|
||||
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.12);
|
||||
}
|
||||
.fm__bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 与容器圆角同值:MP 端 overflow:hidden 裁切 image 子元素不可靠,
|
||||
必须让图片/遮罩自己带圆角,否则「图片圆角、容器方角」穿帮(用户点名) */
|
||||
border-radius: 26rpx;
|
||||
}
|
||||
.fm__shade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 26rpx;
|
||||
background: linear-gradient(180deg, rgba(40, 20, 6, 0.02) 34%, rgba(40, 20, 6, 0.56) 100%);
|
||||
}
|
||||
.fm__body {
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
right: 20rpx;
|
||||
bottom: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.fm__title {
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
text-shadow: 0 2rpx 10rpx rgba(60, 25, 5, 0.45);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm__title--sm {
|
||||
font-size: 27rpx;
|
||||
}
|
||||
.fm__pill {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: 999rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(60, 25, 5, 0.18);
|
||||
}
|
||||
.fm__pill-text {
|
||||
color: #e8531a;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
max-width: 240rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 便捷功能:≥4 张的横滑图片卡(参考图3,主题化) */
|
||||
.fs {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.fs__row {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 4rpx 2rpx 10rpx;
|
||||
}
|
||||
.fs__card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
width: 300rpx;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1rpx solid #ffe9dd;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.10);
|
||||
}
|
||||
.fs__imgwrap {
|
||||
height: 220rpx;
|
||||
background: #fff3e9;
|
||||
}
|
||||
.fs__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.fs__title {
|
||||
margin: 14rpx 16rpx 0;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fs__desc {
|
||||
margin: 4rpx 16rpx 16rpx;
|
||||
font-size: 21rpx;
|
||||
color: #9a7b65;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 发现工具:两列 */
|
||||
.hots {
|
||||
display: grid;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CARD_TEMPLATES } from '@/config/card-templates'
|
||||
import { FEATURE_ENTRIES } from '@/config/features'
|
||||
import { TOOL_REGISTRY } from '@/config/tools'
|
||||
import { useCardStyleStore } from '@/stores/cardStyle'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Z } from '@/utils/z-index'
|
||||
@@ -31,6 +32,7 @@ import { Z } from '@/utils/z-index'
|
||||
const userStore = useUserStore()
|
||||
const toolsStore = useToolsStore()
|
||||
const cardStyle = useCardStyleStore()
|
||||
const feedbackStore = useFeedbackStore()
|
||||
|
||||
const feedbackVisible = ref(false)
|
||||
const feedbackContent = ref('')
|
||||
@@ -49,6 +51,11 @@ const burst = ref(0)
|
||||
|
||||
function openFeedback() {
|
||||
feedbackVisible.value = true
|
||||
// 打开即拉「我的反馈」(此刻服务端未读标记仍在,列表内可显示「新回复」标签),
|
||||
// 随后立刻置已读清掉入口红点。
|
||||
if (userStore.logged) {
|
||||
feedbackStore.fetchMine().then(() => feedbackStore.markRead()).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function openBind() {
|
||||
@@ -309,10 +316,12 @@ async function submitFeedback() {
|
||||
try {
|
||||
await apiFeedbackSubmit(feedbackContent.value.trim(), feedbackContact.value.trim())
|
||||
uni.showToast({ title: '感谢反馈', icon: 'success' })
|
||||
feedbackVisible.value = false
|
||||
feedbackContent.value = ''
|
||||
feedbackContact.value = ''
|
||||
burst.value++
|
||||
// 刷新「我的反馈」列表(抽屉保持打开,用户可直接看到自己刚提交的记录)
|
||||
if (userStore.logged)
|
||||
feedbackStore.fetchMine().catch(() => {})
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
@@ -393,8 +402,43 @@ defineExpose({ openFeedback, openBind, openCardStyle })
|
||||
意见反馈
|
||||
</text>
|
||||
<text class="fb__sub">
|
||||
说说你想用的工具,或遇到的问题
|
||||
说说你想用的工具,或遇到的问题;官方回复会在这里展示
|
||||
</text>
|
||||
|
||||
<!-- 我的反馈:历史记录 + 官方回复(新回复带标签) -->
|
||||
<view v-if="feedbackStore.list.length" class="fb__mine">
|
||||
<view v-for="item in feedbackStore.list" :key="item.id" class="fbm">
|
||||
<view class="fbm__head">
|
||||
<text class="fbm__content">
|
||||
{{ item.content }}
|
||||
</text>
|
||||
<text v-if="item.reply_content && !item.reply_read" class="fbm__new">
|
||||
新回复
|
||||
</text>
|
||||
<text v-else-if="item.status === 1" class="fbm__done">
|
||||
已处理
|
||||
</text>
|
||||
<text v-else class="fbm__pending">
|
||||
待处理
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="item.reply_content" class="fbm__reply">
|
||||
<text class="fbm__reply-label">
|
||||
官方回复
|
||||
</text>
|
||||
<text class="fbm__reply-text">
|
||||
{{ item.reply_content }}
|
||||
</text>
|
||||
<text v-if="item.reply_at" class="fbm__reply-time">
|
||||
{{ item.reply_at }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="fbm__noreply">
|
||||
我们会尽快查看你的反馈
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fb__box">
|
||||
<wd-textarea
|
||||
v-model="feedbackContent"
|
||||
@@ -656,6 +700,83 @@ defineExpose({ openFeedback, openBind, openCardStyle })
|
||||
padding: 6rpx 20rpx;
|
||||
}
|
||||
|
||||
/* 我的反馈列表(官方回复 + 新回复标签) */
|
||||
.fb__mine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
max-height: 420rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fbm {
|
||||
background: var(--tb-glass-bg-soft, rgba(255, 255, 255, 0.42));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.fbm__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.fbm__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 25rpx;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fbm__new {
|
||||
flex-shrink: 0;
|
||||
font-size: 19rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #ff6b35;
|
||||
border-radius: 999rpx;
|
||||
padding: 2rpx 14rpx;
|
||||
}
|
||||
.fbm__done {
|
||||
flex-shrink: 0;
|
||||
font-size: 19rpx;
|
||||
color: #3ecfb2;
|
||||
}
|
||||
.fbm__pending {
|
||||
flex-shrink: 0;
|
||||
font-size: 19rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.fbm__reply {
|
||||
background: rgba(255, 107, 53, 0.07);
|
||||
border-radius: 14rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.fbm__reply-label {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: #ff6b35;
|
||||
}
|
||||
.fbm__reply-text {
|
||||
font-size: 24rpx;
|
||||
color: #3d2a1e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fbm__reply-time {
|
||||
font-size: 19rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.fbm__noreply {
|
||||
font-size: 21rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
|
||||
/* 卡片风格设置(P3:区块 tab + 目标 tab 两级横滑) */
|
||||
.cs {
|
||||
gap: 14rpx;
|
||||
|
||||
@@ -41,7 +41,9 @@ defineEmits<{ (e: 'pick'): void }>()
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
background: #ffffff;
|
||||
background: var(--tb-glass-bg-soft, rgba(255, 255, 255, 0.42));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border: 2rpx dashed #ffd4b8;
|
||||
border-radius: 32rpx;
|
||||
padding: 90rpx 40rpx;
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
* 与「年会查座」等其它画布任务使用**不同 id**,互不冲突。
|
||||
*/
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** 头区主标题 */
|
||||
@@ -60,6 +65,8 @@ function goBack() {
|
||||
<!-- 图片引擎工作画布:onReady 时由 useImageTool → bindImageCanvas('#imgCanvas') 绑定 -->
|
||||
<canvas v-if="needCanvas" id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -54,7 +54,12 @@ withDefaults(defineProps<{
|
||||
width: 300rpx;
|
||||
padding: 44rpx 32rpx;
|
||||
border-radius: 30rpx;
|
||||
background: #ffffff;
|
||||
/* 悬浮在遮罩上的运行框:液态玻璃 */
|
||||
background: var(--tb-glass-bg-strong, rgba(255, 255, 255, 0.68));
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.7);
|
||||
backdrop-filter: blur(24rpx) saturate(1.7);
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 14rpx 36rpx rgba(43, 26, 16, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -193,9 +193,12 @@ function buyMember() {
|
||||
overflow: hidden;
|
||||
padding: 24rpx 24rpx 22rpx;
|
||||
border-radius: 30rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 55%, #241408 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.5);
|
||||
box-shadow: 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
|
||||
/* 深色液态玻璃悬浮面板 */
|
||||
background: linear-gradient(160deg, rgba(43, 26, 16, 0.86) 0%, rgba(61, 36, 19, 0.8) 55%, rgba(36, 20, 8, 0.88) 100%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
border: 1rpx solid rgba(245, 197, 107, 0.4);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.22), 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
|
||||
animation: qPanelIn 0.24s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.qpanel__glow {
|
||||
|
||||
@@ -127,9 +127,20 @@ function goMyQuota() {
|
||||
overflow: hidden;
|
||||
padding: 28rpx 26rpx 26rpx;
|
||||
border-radius: 34rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 52%, #22130a 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
/* 阴影/内高光来自公共 token .tb-elev-gold(R2-1) */
|
||||
/* 深色液态玻璃:深炭半透明 + backdrop 模糊,金光 aura/扫光叠在其上(色相不变) */
|
||||
background:
|
||||
radial-gradient(120% 92% at 8% -14%, rgba(255, 236, 200, 0.14), rgba(255, 236, 200, 0) 62%),
|
||||
linear-gradient(160deg, rgba(43, 26, 16, 0.82) 0%, rgba(61, 36, 19, 0.76) 52%, rgba(34, 19, 10, 0.86) 100%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
border: 1rpx solid rgba(245, 197, 107, 0.45);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.28), 0 18rpx 42rpx rgba(43, 26, 16, 0.34);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.vcard {
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 52%, #22130a 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
}
|
||||
}
|
||||
.vcard__aura {
|
||||
position: absolute;
|
||||
|
||||
@@ -233,9 +233,12 @@ function goPack() {
|
||||
width: 500rpx;
|
||||
padding: 26rpx 26rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 55%, #241408 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
|
||||
/* 深色液态玻璃悬浮卡 */
|
||||
background: linear-gradient(160deg, rgba(43, 26, 16, 0.86) 0%, rgba(61, 36, 19, 0.8) 55%, rgba(36, 20, 8, 0.88) 100%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
border: 1rpx solid rgba(245, 197, 107, 0.45);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.22), 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
|
||||
animation: vipPanelIn 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.panel__glow {
|
||||
|
||||
@@ -206,7 +206,7 @@ export interface CardSection {
|
||||
/** 全局默认(解析顺序末级兜底;仅在「区块默认也拿不到」时命中) */
|
||||
export const GLOBAL_DEFAULT_STYLE: CardStyle = {
|
||||
template: 'iconTop',
|
||||
variant: 'tint',
|
||||
variant: 'glass',
|
||||
tone: 'gold',
|
||||
}
|
||||
|
||||
@@ -223,20 +223,21 @@ const TPL_FULL = ['iconTop', 'iconLeft', 'lead', 'duo', 'notch', 'arch', 'leaf',
|
||||
|
||||
/**
|
||||
* 首页区块清单(按源码位置登记;key 见派单表)。
|
||||
* 默认样式偏「瓷片区」观感:主卡/发现用渐变插画版式,宫格用图标墙。
|
||||
* 默认样式(液态玻璃迭代):卡片一律 glass 玻璃皮肤,色系沿用原配色;
|
||||
* group 楼层容器版式固定,内部小卡用 tint(容器内玻璃叠玻璃易浑浊)。
|
||||
*
|
||||
* 注:「我的」页已**不再使用卡片风格体系**(相关页面正回滚),
|
||||
* 故 `mine.stats` / `mine.cells` 两个区块已移除,避免用户配了却看不见效果。
|
||||
*/
|
||||
export const CARD_SECTIONS: CardSection[] = [
|
||||
{ key: 'workbench.lead', label: '工作台 · 通栏主卡', default: { template: 'lead', variant: 'porcelain', tone: 'gold' }, members: [], memberSource: 'workbench', templates: TPL_FULL },
|
||||
{ key: 'workbench.sub', label: '工作台 · 副卡', default: { template: 'duo', variant: 'porcelain', tone: 'mint' }, members: [], memberSource: 'workbench', templates: TPL_NARROW },
|
||||
{ key: 'workbench.grid', label: '工作台 · 工具宫格', default: { template: 'iconTop', variant: 'tint', tone: 'blue' }, members: [], memberSource: 'workbench', templates: TPL_NARROW },
|
||||
{ key: 'workbench.features', label: '工作台 · 便捷功能', default: { template: 'iconTop', variant: 'porcelain', tone: 'gold' }, members: [], memberSource: 'features', templates: TPL_NARROW },
|
||||
{ key: 'workbench.hot', label: '工作台 · 发现工具', default: { template: 'duo', variant: 'porcelain', tone: 'violet' }, members: [], memberSource: 'hot', templates: TPL_NARROW },
|
||||
{ key: 'workbench.lead', label: '工作台 · 通栏主卡', default: { template: 'lead', variant: 'glass', tone: 'gold' }, members: [], memberSource: 'workbench', templates: TPL_FULL },
|
||||
{ key: 'workbench.sub', label: '工作台 · 副卡', default: { template: 'duo', variant: 'glass', tone: 'mint' }, members: [], memberSource: 'workbench', templates: TPL_NARROW },
|
||||
{ key: 'workbench.grid', label: '工作台 · 工具宫格', default: { template: 'iconTop', variant: 'glass', tone: 'blue' }, members: [], memberSource: 'workbench', templates: TPL_NARROW },
|
||||
{ key: 'workbench.features', label: '工作台 · 便捷功能', default: { template: 'iconTop', variant: 'glass', tone: 'gold' }, members: [], memberSource: 'features', templates: TPL_NARROW },
|
||||
{ key: 'workbench.hot', label: '工作台 · 发现工具', default: { template: 'duo', variant: 'glass', tone: 'violet' }, members: [], memberSource: 'hot', templates: TPL_NARROW },
|
||||
{ key: 'library.search', label: '工具库 · 搜索卡', default: { template: 'iconLeft', variant: 'glass', tone: 'gold' }, members: [], templates: TPL_FULL },
|
||||
{ key: 'library.grid', label: '工具库 · 搜索宫格', default: { template: 'iconTop', variant: 'tint', tone: 'blue' }, members: [], templates: TPL_NARROW, hint: '搜索结果为动态列表,按区块统一设置' },
|
||||
{ key: 'library.floor', label: '工具库 · 模块楼层', default: { template: 'group', variant: 'tint', tone: 'mint' }, members: [], templates: TPL_CONTAINER, hint: '楼层是容器卡,版式固定为「分组容器卡」才能容纳里面的工具小卡;里面的工具小卡在「楼层工具小卡」区块单独设置' },
|
||||
{ key: 'library.grid', label: '工具库 · 搜索宫格', default: { template: 'iconTop', variant: 'glass', tone: 'blue' }, members: [], templates: TPL_NARROW, hint: '搜索结果为动态列表,按区块统一设置' },
|
||||
{ key: 'library.floor', label: '工具库 · 模块楼层', default: { template: 'group', variant: 'glass', tone: 'mint' }, members: [], templates: TPL_CONTAINER, hint: '楼层是容器卡,版式固定为「分组容器卡」才能容纳里面的工具小卡;里面的工具小卡在「楼层工具小卡」区块单独设置' },
|
||||
{ key: 'library.tile', label: '工具库 · 楼层工具小卡', default: { template: 'iconTop', variant: 'tint', tone: 'blue' }, members: [], memberSource: 'floors', templates: TPL_NARROW, hint: '楼层里的工具小卡:默认「图标在上、文字横排在下」(窄列,不含容器版式)' },
|
||||
]
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ export interface FeatureEntry {
|
||||
*/
|
||||
export const NOTE_REMIND_TMPL_ID = ''
|
||||
|
||||
/**
|
||||
* 开发项目维护费提醒的订阅消息模板 ID。
|
||||
*
|
||||
* ⚠️ 必须与后端 `manifest/config/config.yaml` 的 `wx.subscribe.projectRemindTemplateId` 保持一致。
|
||||
* 为空时的降级(两端对称、绝不报错):
|
||||
* - 前端:确认验收时不发起 requestSubscribeMessage;
|
||||
* - 后端:维护费到期只发站内信(管理端横幅仍常驻可见)。
|
||||
*/
|
||||
export const PROJECT_REMIND_TMPL_ID = ''
|
||||
|
||||
/** 功能入口列表(我的页渲染来源) */
|
||||
export const FEATURE_ENTRIES: FeatureEntry[] = [
|
||||
{
|
||||
@@ -67,14 +77,25 @@ export const FEATURE_ENTRIES: FeatureEntry[] = [
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'cardLibrary',
|
||||
name: '卡片样式库',
|
||||
icon: 'photo',
|
||||
illustration: 'sparkle',
|
||||
desc: '20 组卡片设计复刻',
|
||||
path: '/pkg-style/showcase',
|
||||
bg: '#ECE2FF',
|
||||
color: '#5B2E9E',
|
||||
tone: 'violet',
|
||||
key: 'service',
|
||||
name: '软件开发',
|
||||
icon: 'computer',
|
||||
illustration: 'rocket',
|
||||
desc: '定制开发 · 在线报价',
|
||||
path: '/pkg-service/home',
|
||||
bg: '#DFF7F0',
|
||||
color: '#0FA385',
|
||||
tone: 'mint',
|
||||
},
|
||||
// {
|
||||
// key: 'cardLibrary',
|
||||
// name: '卡片样式库',
|
||||
// icon: 'photo',
|
||||
// illustration: 'sparkle',
|
||||
// desc: '20 组卡片设计复刻',
|
||||
// path: '/pkg-style/showcase',
|
||||
// bg: '#ECE2FF',
|
||||
// color: '#5B2E9E',
|
||||
// tone: 'violet',
|
||||
// },
|
||||
]
|
||||
|
||||
@@ -156,12 +156,88 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pkg-service",
|
||||
"pages": [
|
||||
{
|
||||
"path": "home",
|
||||
"style": {
|
||||
"navigationBarTitleText": "软件开发服务"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "works",
|
||||
"style": {
|
||||
"navigationBarTitleText": "作品案例"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "work-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "作品详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "quote",
|
||||
"style": {
|
||||
"navigationBarTitleText": "申请报价"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "quotes",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的报价"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "project-bind",
|
||||
"style": {
|
||||
"navigationBarTitleText": "绑定开发项目"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "projects",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的项目"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "project-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "项目详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "服务与价格"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "服务详情"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pkg-msg",
|
||||
"pages": [
|
||||
{
|
||||
"path": "index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息中心"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"pages/index/index": {
|
||||
"network": "wifi",
|
||||
"packages": ["pkg-member", "pkg-note", "pkg-style"]
|
||||
"packages": ["pkg-member", "pkg-note", "pkg-style", "pkg-service", "pkg-msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,10 +212,12 @@ async function devLogin() {
|
||||
z-index: 1;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
background: var(--tb-glass-bg-strong, rgba(255, 255, 255, 0.68));
|
||||
border-radius: 36rpx;
|
||||
border: 1rpx solid rgba(255, 195, 155, 0.5);
|
||||
box-shadow: 0 18rpx 44rpx rgba(107, 47, 15, 0.1);
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.7);
|
||||
backdrop-filter: blur(24rpx) saturate(1.7);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 18rpx 44rpx rgba(107, 47, 15, 0.1);
|
||||
padding: 32rpx 32rpx 40rpx;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ import { useUserStore } from '@/stores/user'
|
||||
import { AVATAR_PRESETS, presetOf } from '@/utils/avatar'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
/** 导航栏顶部留白:MP 用真实状态栏高度 */
|
||||
const navPad = navPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const nickname = ref('')
|
||||
const avatarUrl = ref('')
|
||||
const saving = ref(false)
|
||||
@@ -173,6 +178,8 @@ function goBack() {
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -233,12 +240,14 @@ function goBack() {
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(255, 195, 155, 0.5);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 30rpx;
|
||||
padding: 26rpx 28rpx;
|
||||
margin-top: 24rpx;
|
||||
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.card__title {
|
||||
display: block;
|
||||
|
||||
@@ -209,11 +209,13 @@ async function saveAll() {
|
||||
margin-right: 64rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.quality {
|
||||
display: flex;
|
||||
@@ -274,8 +276,9 @@ async function saveAll() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
/* 卡内嵌套面:只半透明不加模糊(外层卡已 blur) */
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
border-radius: 28rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import { useUserStore } from '@/stores/user'
|
||||
import { toolPalette } from '@/utils/palette'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { showUnlockGuide } from '@/utils/unlock-guide'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
const userStore = useUserStore()
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -76,6 +81,8 @@ toolsStore.fetchAll().catch(() => {})
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -400,13 +400,16 @@ async function save() {
|
||||
padding: 12rpx 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.card__title {
|
||||
font-size: 26rpx;
|
||||
|
||||
@@ -462,13 +462,16 @@ async function save() {
|
||||
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.28);
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.card__title {
|
||||
font-size: 26rpx;
|
||||
|
||||
@@ -200,13 +200,16 @@ async function save() {
|
||||
padding: 8rpx 20rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -13,6 +15,9 @@ const saving = ref(false)
|
||||
/** 导航栏顶部留白:MP 用真实状态栏高度 */
|
||||
const navPad = navPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -159,6 +164,8 @@ async function save() {
|
||||
保存工作台
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { apiEventList } from '@/api/event'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const list = ref<EventListItem[]>([])
|
||||
const loadError = ref('')
|
||||
@@ -123,6 +128,8 @@ function goBack() {
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -34,9 +34,14 @@ import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import SeatMap from '@/components/event/SeatMap.vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
/** 返回上一页 */
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
@@ -369,6 +374,8 @@ onUnload(() => {
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -14,10 +14,15 @@ import { COUPON_STATUS_EXPIRED, COUPON_STATUS_UNUSED, COUPON_STATUS_USED, apiCou
|
||||
import CouponCard from '@/components/coupon/CouponCard.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const navPad = navPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
/** 分栏:status 为 undefined 表示「全部」 */
|
||||
interface TabItem {
|
||||
key: string
|
||||
@@ -168,6 +173,8 @@ function onDetail(coupon: UserCoupon) {
|
||||
<view v-else class="loading">
|
||||
正在加载…
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -30,12 +30,17 @@ import { fenToYuan, payMemberOrder } from '@/utils/member-pay'
|
||||
import { payQuotaOrder } from '@/utils/quota-pay'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const toolsStore = useToolsStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const navPad = navPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const outTradeNo = ref('')
|
||||
const orderType = ref(0)
|
||||
const targetKey = ref('')
|
||||
@@ -484,6 +489,7 @@ onShow(() => {
|
||||
<!--
|
||||
优惠券选择抽屉:**页面根级**渲染(不传 root-portal,规避「scroll-view 内 root-portal 失效」旧坑)。
|
||||
层级取 Z.sheet(2000) > 底部操作条 stickyBar(900),完整盖过操作条,二者不打架。
|
||||
UI(R5 重做):券面风格——左侧面额区 + 虚线切口 + 右侧信息区,选中金框 + 打勾。
|
||||
-->
|
||||
<wd-popup
|
||||
v-model="couponDrawerVisible"
|
||||
@@ -493,54 +499,90 @@ onShow(() => {
|
||||
>
|
||||
<view class="cd">
|
||||
<view class="cd__grip" />
|
||||
<text class="cd__title">
|
||||
选择优惠券
|
||||
</text>
|
||||
<text class="cd__sub">
|
||||
与优惠码互斥,选择后立即生效
|
||||
</text>
|
||||
<view class="cd__head">
|
||||
<text class="cd__title">
|
||||
选择优惠券
|
||||
</text>
|
||||
<text class="cd__sub">
|
||||
与优惠码互斥,选择后立即生效
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="cd__list" :show-scrollbar="false">
|
||||
<!-- 不使用优惠券(置顶;当前未选券时高亮打勾) -->
|
||||
<!-- 不使用优惠券:通栏素面选项 -->
|
||||
<view
|
||||
class="cp cd__none press"
|
||||
:class="{ 'cp--on': !appliedCouponId }"
|
||||
class="none press"
|
||||
:class="{ 'none--on': !appliedCouponId }"
|
||||
hover-class="press-active"
|
||||
@tap="onUseNoneCoupon"
|
||||
>
|
||||
<view class="cp__body">
|
||||
<text class="cp__title">不使用优惠券</text>
|
||||
<text class="cp__sub">以原价支付本单</text>
|
||||
<view class="none__icon">
|
||||
<wd-icon name="close" size="30rpx" color="#9a7b65" />
|
||||
</view>
|
||||
<view v-if="!appliedCouponId" class="cp__on">
|
||||
<wd-icon name="check" size="22rpx" color="#ffffff" />
|
||||
<view class="none__body">
|
||||
<text class="none__title">
|
||||
不使用优惠券
|
||||
</text>
|
||||
<text class="none__sub">
|
||||
以原价支付本单
|
||||
</text>
|
||||
</view>
|
||||
<view class="radio" :class="{ 'radio--on': !appliedCouponId }">
|
||||
<wd-icon v-if="!appliedCouponId" name="check" size="22rpx" color="#ffffff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 全部可选券(含当前已选中的那张,打勾标识) -->
|
||||
<!-- 可选券(券面):左面额 + 虚线切口 + 右信息 -->
|
||||
<view
|
||||
v-for="c in coupons"
|
||||
:key="c.user_coupon_id"
|
||||
class="cp press"
|
||||
:class="{ 'cp--on': c.user_coupon_id === appliedCouponId }"
|
||||
class="tk press"
|
||||
:class="{ 'tk--on': c.user_coupon_id === appliedCouponId }"
|
||||
hover-class="press-active"
|
||||
@tap="onPickCoupon(c.user_coupon_id)"
|
||||
>
|
||||
<view class="cp__body">
|
||||
<text class="cp__title">{{ c.title }}</text>
|
||||
<text class="cp__sub">有效期至 {{ c.expire_at || '长期' }}</text>
|
||||
<!-- 左:面额区 -->
|
||||
<view class="tk__left">
|
||||
<text class="tk__amount">
|
||||
{{ c.is_freebie ? '免单' : '优惠' }}
|
||||
</text>
|
||||
<text class="tk__amount-sub">
|
||||
整单抵扣
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="c.user_coupon_id === appliedCouponId" class="cp__on">
|
||||
<wd-icon name="check" size="22rpx" color="#ffffff" />
|
||||
<!-- 中缝切口 -->
|
||||
<view class="tk__notch" />
|
||||
<!-- 右:信息区 -->
|
||||
<view class="tk__body">
|
||||
<text class="tk__title">
|
||||
{{ c.title }}
|
||||
</text>
|
||||
<text class="tk__expire">
|
||||
有效期至 {{ c.expire_at || '长期' }}
|
||||
</text>
|
||||
<view class="tk__tags">
|
||||
<view class="tk__tag">
|
||||
免单券
|
||||
</view>
|
||||
<view v-if="c.user_coupon_id === appliedCouponId" class="tk__tag tk__tag--using">
|
||||
使用中
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="radio" :class="{ 'radio--on': c.user_coupon_id === appliedCouponId }">
|
||||
<wd-icon v-if="c.user_coupon_id === appliedCouponId" name="check" size="22rpx" color="#ffffff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!coupons.length" class="cd__empty">
|
||||
暂无可用的免单券
|
||||
</view>
|
||||
<view class="cd__safe" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -615,12 +657,14 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(255, 195, 155, 0.5);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 30rpx;
|
||||
padding: 26rpx 28rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
|
||||
}
|
||||
.card__title {
|
||||
display: block;
|
||||
@@ -747,11 +791,11 @@ onShow(() => {
|
||||
color: #b3907c;
|
||||
}
|
||||
|
||||
/* 优惠券选择抽屉(页面根级 + sheet 层级) */
|
||||
/* 优惠券选择抽屉(页面根级 + sheet 层级;R5 券面风格重做) */
|
||||
.cd {
|
||||
background: #ffffff;
|
||||
background: #fff9f2;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 20rpx 32rpx calc(36rpx + env(safe-area-inset-bottom, 0px));
|
||||
padding: 20rpx 28rpx calc(30rpx + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -761,70 +805,199 @@ onShow(() => {
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffe3d3;
|
||||
margin-bottom: 16rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.cd__head {
|
||||
padding: 0 6rpx 4rpx;
|
||||
}
|
||||
.cd__title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.cd__sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
font-size: 23rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.cd__list {
|
||||
max-height: 52vh;
|
||||
margin-top: 20rpx;
|
||||
max-height: 56vh;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.cd__empty {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
padding: 50rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.cp {
|
||||
position: relative;
|
||||
.cd__safe {
|
||||
height: 8rpx;
|
||||
}
|
||||
|
||||
/* 「不使用优惠券」:素面通栏选项 */
|
||||
.none {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
/* 抽屉内逐条堆叠的间距(原内联列表靠 .coupons 的 gap,已移除) */
|
||||
margin-bottom: 14rpx;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe9dd;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.cp--on {
|
||||
background: #fff4d6;
|
||||
border-color: #f5c56b;
|
||||
box-shadow: 0 8rpx 18rpx rgba(224, 166, 63, 0.2);
|
||||
.none--on {
|
||||
border-color: #ffb27d;
|
||||
background: #fff4ec;
|
||||
}
|
||||
.cp__body {
|
||||
.none__icon {
|
||||
flex-shrink: 0;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #f6efe8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.none__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cp__title {
|
||||
.none__title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.cp__sub {
|
||||
.none__sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.cp__on {
|
||||
|
||||
/* 选中圆点(none / ticket 共用) */
|
||||
.radio {
|
||||
flex-shrink: 0;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: #0fa385;
|
||||
border: 3rpx solid #e2d3c6;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.radio--on {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
|
||||
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
|
||||
/* 券面票卡:左面额区 + 虚线切口 + 右信息区 */
|
||||
.tk {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe9dd;
|
||||
margin-bottom: 18rpx;
|
||||
padding-right: 20rpx;
|
||||
}
|
||||
.tk--on {
|
||||
border-color: #f5c56b;
|
||||
background: #fffaf0;
|
||||
box-shadow: 0 10rpx 24rpx rgba(224, 166, 63, 0.22);
|
||||
}
|
||||
.tk__left {
|
||||
flex-shrink: 0;
|
||||
width: 168rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
padding: 26rpx 10rpx;
|
||||
background: linear-gradient(135deg, #fff4d6 0%, #ffe3a8 100%);
|
||||
}
|
||||
.tk--on .tk__left {
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 100%);
|
||||
}
|
||||
.tk__amount {
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: #3d2413;
|
||||
}
|
||||
.tk__amount-sub {
|
||||
font-size: 19rpx;
|
||||
color: rgba(61, 36, 19, 0.65);
|
||||
}
|
||||
/* 中缝:虚线 + 上下半圆切口(切口圆用页面底色) */
|
||||
.tk__notch {
|
||||
flex-shrink: 0;
|
||||
width: 2rpx;
|
||||
margin: 16rpx 0;
|
||||
border-left: 2rpx dashed rgba(61, 36, 19, 0.22);
|
||||
position: relative;
|
||||
}
|
||||
.tk__notch::before,
|
||||
.tk__notch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -13rpx;
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff9f2;
|
||||
}
|
||||
.tk__notch::before {
|
||||
top: -26rpx;
|
||||
}
|
||||
.tk__notch::after {
|
||||
bottom: -26rpx;
|
||||
}
|
||||
.tk__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
padding: 20rpx 0 20rpx 20rpx;
|
||||
}
|
||||
.tk__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tk__expire {
|
||||
font-size: 21rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.tk__tags {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
.tk__tag {
|
||||
font-size: 19rpx;
|
||||
padding: 3rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #b57f00;
|
||||
background: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
.tk__tag--using {
|
||||
color: #0fa385;
|
||||
background: #dff7f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.foot {
|
||||
|
||||
@@ -17,11 +17,16 @@ import { useUserStore } from '@/stores/user'
|
||||
import { createMemberOrder, fenToYuan } from '@/utils/member-pay'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const center = ref<MemberCenter | null>(null)
|
||||
const selected = ref('')
|
||||
@@ -380,6 +385,8 @@ async function pay() {
|
||||
<text>{{ paying ? '处理中…' : '立即开通' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -451,9 +458,12 @@ async function pay() {
|
||||
margin-top: 16rpx;
|
||||
padding: 30rpx 28rpx;
|
||||
border-radius: 34rpx;
|
||||
/* 深色液态玻璃:半透明金调 + backdrop 模糊(悬浮在深炭 hero 上) */
|
||||
background: linear-gradient(150deg, rgba(255, 233, 184, 0.16) 0%, rgba(255, 255, 255, 0.06) 60%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: 0 16rpx 40rpx rgba(20, 10, 4, 0.35);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
backdrop-filter: blur(24rpx) saturate(1.4);
|
||||
border: 1rpx solid rgba(245, 197, 107, 0.45);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.25), 0 16rpx 40rpx rgba(20, 10, 4, 0.35);
|
||||
}
|
||||
.mb-card__sweep {
|
||||
position: absolute;
|
||||
@@ -596,12 +606,14 @@ async function pay() {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 22rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的订单(R4:四态页签 + 左右滑动切换)。
|
||||
*
|
||||
* tab:0全部 1待付款 2已生效 3退款(后端 /member/orders 按 tab 过滤)。
|
||||
* 每个页签独立懒加载(切到才拉),swiper 横滑与点签双向联动;
|
||||
* 待支付订单整卡可点进详情,由用户在详情页确认金额/优惠后再拉起收银台。
|
||||
*/
|
||||
import type { MemberOrder } from '@/api/member'
|
||||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiMemberOrderCheck, apiMemberOrders } from '@/api/member'
|
||||
import {
|
||||
apiMemberOrderCheck, apiMemberOrders,
|
||||
} from '@/api/member'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { fenToYuan } from '@/utils/member-pay'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const toolsStore = useToolsStore()
|
||||
/** 导航栏顶部留白:MP 用真实状态栏高度 */
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const orders = ref<MemberOrder[]>([])
|
||||
const TABS = [
|
||||
{ key: 0, label: '全部', empty: '还没有订单' },
|
||||
{ key: 1, label: '待付款', empty: '没有待付款的订单' },
|
||||
{ key: 2, label: '已生效', empty: '没有已生效的订单' },
|
||||
{ key: 3, label: '退款', empty: '没有退款记录' },
|
||||
] as const
|
||||
|
||||
const current = ref(0)
|
||||
const loading = ref(false)
|
||||
/** 每个页签独立的数据与加载标记(懒加载:切到才拉) */
|
||||
const lists = ref<Record<number, MemberOrder[]>>({})
|
||||
const loaded = ref<Record<number, boolean>>({})
|
||||
const checking = ref('')
|
||||
|
||||
const hasPending = computed(() => orders.value.some(o => o.status === 0))
|
||||
|
||||
async function load() {
|
||||
async function loadTab(tab = current.value, force = false) {
|
||||
if (loading.value)
|
||||
return
|
||||
if (loaded.value[tab] && !force)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
orders.value = (await apiMemberOrders(30)).list
|
||||
const res = await apiMemberOrders(30, tab)
|
||||
lists.value = { ...lists.value, [tab]: res.list || [] }
|
||||
loaded.value = { ...loaded.value, [tab]: true }
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
@@ -32,13 +59,41 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
onShow(() => {
|
||||
// 从详情/支付回来强制刷新当前页签(支付结果要及时反映)
|
||||
loadTab(current.value, true)
|
||||
})
|
||||
|
||||
/** 支持外部带 tab 直达(我的页订单入库四态):/pkg-member/member/orders?tab=1 */
|
||||
onLoad((options) => {
|
||||
const raw = Number(options?.tab || 0)
|
||||
if (Number.isFinite(raw) && TABS.some(t => t.key === raw))
|
||||
current.value = raw
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await load()
|
||||
await loadTab(current.value, true)
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
function onTabChange(e: any) {
|
||||
const next = Number(e?.detail?.current)
|
||||
if (Number.isFinite(next) && TABS.some(t => t.key === next)) {
|
||||
current.value = next
|
||||
loadTab(next)
|
||||
}
|
||||
}
|
||||
|
||||
function pickTab(key: number) {
|
||||
current.value = key
|
||||
loadTab(key)
|
||||
}
|
||||
|
||||
/** 页签头滚动定位(点右边的签时把头滚过去) */
|
||||
function tabClass(key: number) {
|
||||
return { 'tabs__item--on': current.value === key }
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -73,7 +128,7 @@ async function checkOrder(order: MemberOrder) {
|
||||
if (res.delivered) {
|
||||
uni.showToast({ title: order.order_type === 2 ? '次数已到账' : '已开通', icon: 'success' })
|
||||
await Promise.all([
|
||||
load(),
|
||||
loadTab(current.value, true),
|
||||
userStore.refreshLevel().catch(() => {}),
|
||||
toolsStore.fetchAll().catch(() => {}),
|
||||
])
|
||||
@@ -89,6 +144,20 @@ async function checkOrder(order: MemberOrder) {
|
||||
checking.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const currentList = computed(() => lists.value[current.value] || [])
|
||||
|
||||
/** 页签内下拉刷新(页面级 onPullDownRefresh 在 100vh + 内滚布局下不触发,故用 scroll-view refresher) */
|
||||
const refreshingTab = ref(-1)
|
||||
async function onRefresh(tab: number) {
|
||||
refreshingTab.value = tab
|
||||
try {
|
||||
await loadTab(tab, true)
|
||||
}
|
||||
finally {
|
||||
refreshingTab.value = -1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,123 +172,157 @@ async function checkOrder(order: MemberOrder) {
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view v-if="orders.length" class="list">
|
||||
<!-- 页签(点击与横滑双向联动) -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="o in orders"
|
||||
:key="o.out_trade_no"
|
||||
class="tb-card item"
|
||||
:class="{ 'item--tappable': o.status === 0 }"
|
||||
@tap="o.status === 0 && goDetail(o)"
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
class="tabs__item"
|
||||
:class="tabClass(t.key)"
|
||||
@tap="pickTab(t.key)"
|
||||
>
|
||||
<view class="item__top">
|
||||
<text class="item__name">
|
||||
{{ o.title || o.plan_name || o.plan_key || '订单' }}
|
||||
</text>
|
||||
<view class="item__status" :class="`item__status--${o.status}`">
|
||||
{{ o.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="o.order_type === 2" class="item__row">
|
||||
<text class="item__label">
|
||||
次数包
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.tool_name || o.tool_key || '—' }} · {{ o.times }} 次
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
订单号
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.out_trade_no }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
金额
|
||||
</text>
|
||||
<text class="item__value item__value--price">
|
||||
¥{{ fenToYuan(o.price_cents) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
下单时间
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.created_at || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="o.delivered_at" class="item__row">
|
||||
<text class="item__label">
|
||||
{{ o.order_type === 2 ? '到账时间' : '开通时间' }}
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.delivered_at }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="o.status === 0" class="item__actions">
|
||||
<view
|
||||
class="item__btn press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="checkOrder(o)"
|
||||
>
|
||||
<text>{{ checking === o.out_trade_no ? '查询中…' : '查询状态' }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="item__btn item__btn--primary press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="goDetail(o)"
|
||||
>
|
||||
<text>支付</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="o.status === 2 || o.status === 3" class="item__actions">
|
||||
<view
|
||||
class="item__btn item__btn--primary press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="goBuy(o)"
|
||||
>
|
||||
<text>重新购买</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hasPending" class="tip">
|
||||
<text>待支付订单点「支付」或点整张卡片都能进入订单详情,确认金额/优惠后再拉起收银台;已发货订单不可再次支付,需要时请联系客服。</text>
|
||||
<text class="tabs__label">
|
||||
{{ t.label }}
|
||||
</text>
|
||||
<view class="tabs__bar" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loading" class="empty-wrap">
|
||||
<view class="tb-empty">
|
||||
<text class="tb-empty__title">
|
||||
还没有订单
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
开通会员后可在这里查看购买记录
|
||||
</text>
|
||||
</view>
|
||||
<view class="empty-btn press" hover-class="press-active" @tap="goBuy">
|
||||
<text>去开通会员</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 横滑页签内容 -->
|
||||
<swiper class="swipe" :current="current" :duration="240" @change="onTabChange">
|
||||
<swiper-item v-for="t in TABS" :key="t.key" class="swipe__item">
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="swipe__scroll"
|
||||
:show-scrollbar="false"
|
||||
:refresher-enabled="true"
|
||||
refresher-default-style="black"
|
||||
:refresher-triggered="refreshingTab === t.key"
|
||||
@refresherrefresh="onRefresh(t.key)"
|
||||
>
|
||||
<view v-if="lists[t.key]?.length" class="list">
|
||||
<view
|
||||
v-for="o in lists[t.key]"
|
||||
:key="o.out_trade_no"
|
||||
class="tb-card item"
|
||||
:class="{ 'item--tappable': o.status === 0 }"
|
||||
@tap="o.status === 0 && goDetail(o)"
|
||||
>
|
||||
<view class="item__top">
|
||||
<text class="item__name">
|
||||
{{ o.title || o.plan_name || o.plan_key || '订单' }}
|
||||
</text>
|
||||
<view class="item__status" :class="`item__status--${o.status}`">
|
||||
{{ o.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="o.order_type === 2" class="item__row">
|
||||
<text class="item__label">
|
||||
次数包
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.tool_name || o.tool_key || '—' }} · {{ o.times }} 次
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
订单号
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.out_trade_no }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
金额
|
||||
</text>
|
||||
<text class="item__value item__value--price">
|
||||
¥{{ fenToYuan(o.price_cents) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
下单时间
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.created_at || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="o.delivered_at" class="item__row">
|
||||
<text class="item__label">
|
||||
{{ o.order_type === 2 ? '到账时间' : '开通时间' }}
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.delivered_at }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="o.status === 0" class="item__actions">
|
||||
<view
|
||||
class="item__btn press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="checkOrder(o)"
|
||||
>
|
||||
<text>{{ checking === o.out_trade_no ? '查询中…' : '查询状态' }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="item__btn item__btn--primary press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="goDetail(o)"
|
||||
>
|
||||
<text>支付</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="o.status === 2 || o.status === 3" class="item__actions">
|
||||
<view
|
||||
class="item__btn item__btn--primary press"
|
||||
hover-class="press-active"
|
||||
@tap.stop="goBuy(o)"
|
||||
>
|
||||
<text>重新购买</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loading || loaded[t.key]" class="empty-wrap">
|
||||
<view class="tb-empty">
|
||||
<text class="tb-empty__title">
|
||||
{{ t.empty }}
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
开通会员后可在这里查看购买记录
|
||||
</text>
|
||||
</view>
|
||||
<view class="empty-btn press" hover-class="press-active" @tap="goBuy">
|
||||
<text>去开通会员</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !lists[t.key]?.length" class="hint">
|
||||
正在加载…
|
||||
</view>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0 20rpx;
|
||||
padding: 16rpx 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
@@ -244,6 +347,56 @@ async function checkOrder(order: MemberOrder) {
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
/* 页签条:下划线式,选中项橙色加粗 */
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
padding: 4rpx 24rpx 0;
|
||||
background: rgba(255, 249, 242, 0.9);
|
||||
}
|
||||
.tabs__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16rpx 0 0;
|
||||
}
|
||||
.tabs__label {
|
||||
font-size: 28rpx;
|
||||
color: #9a7b65;
|
||||
transition: all 0.2s var(--tb-ease);
|
||||
}
|
||||
.tabs__item--on .tabs__label {
|
||||
color: #fb4e14;
|
||||
font-weight: 800;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tabs__bar {
|
||||
margin-top: 12rpx;
|
||||
width: 56rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
background: transparent;
|
||||
transition: background 0.2s var(--tb-ease);
|
||||
}
|
||||
.tabs__item--on .tabs__bar {
|
||||
background: linear-gradient(90deg, #ff9f4d, #ff6b35);
|
||||
}
|
||||
|
||||
/* swiper 占满剩余高度;每个 swiper-item 内自持滚动 */
|
||||
.swipe {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
.swipe__item {
|
||||
height: 100%;
|
||||
}
|
||||
.swipe__scroll {
|
||||
height: 100%;
|
||||
padding: 20rpx 32rpx calc(40rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
@@ -336,18 +489,18 @@ async function checkOrder(order: MemberOrder) {
|
||||
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
|
||||
}
|
||||
|
||||
.tip {
|
||||
padding: 8rpx 12rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
color: #b3907c;
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 60rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding-top: 100rpx;
|
||||
}
|
||||
.empty-btn {
|
||||
margin-top: 40rpx;
|
||||
margin: 40rpx 120rpx 0;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
|
||||
@@ -20,11 +20,16 @@ import { fenToYuan } from '@/utils/member-pay'
|
||||
import { createQuotaOrder } from '@/utils/quota-pay'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { Z } from '@/utils/z-index'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const paying = ref(false)
|
||||
const toolKey = ref('')
|
||||
@@ -320,6 +325,8 @@ async function pay() {
|
||||
立即购买
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -419,12 +426,14 @@ async function pay() {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 28rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
@@ -501,8 +510,9 @@ async function pay() {
|
||||
gap: 4rpx;
|
||||
padding: 26rpx 12rpx 22rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #fff9f2;
|
||||
border: 3rpx solid #ffe3d3;
|
||||
/* 卡内嵌套价签:只半透明不加模糊(外层卡已 blur) */
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.pack--on {
|
||||
background: linear-gradient(170deg, #2b1a10 0%, #3d2413 100%);
|
||||
|
||||
@@ -12,11 +12,16 @@ import { computed, ref } from 'vue'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const tools = computed<ToolQuota[]>(() => quotaStore.mine?.tools || [])
|
||||
@@ -228,6 +233,8 @@ function sourceText(t: ToolQuota): string {
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -382,11 +389,13 @@ function sourceText(t: ToolQuota): string {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
background: var(--tb-glass-bg, rgba(255, 255, 255, 0.55));
|
||||
border: 1rpx solid var(--tb-glass-line, rgba(255, 255, 255, 0.65));
|
||||
-webkit-backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
backdrop-filter: blur(var(--tb-glass-blur, 20rpx)) saturate(var(--tb-glass-saturate, 1.6));
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
|
||||
258
src/pkg-msg/index.vue
Normal file
258
src/pkg-msg/index.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 消息中心(分包 pkg-msg)。
|
||||
*
|
||||
* 站内信列表:会员开通/次数包到账、报价回执、报价处理结果、反馈回复等系统通知。
|
||||
* 未读条目带橙色圆点;进入本页拉取列表后自动全部置已读(与「意见反馈」抽屉同款
|
||||
* 两步语义:先看徽标 → 进来清零)。带 link 的消息点击可跳转对应页面。
|
||||
*/
|
||||
import type { MessageItem } from '@/api/message'
|
||||
import {
|
||||
MSG_TYPE_FEEDBACK_REPLIED, MSG_TYPE_ORDER_DELIVERED, MSG_TYPE_QUOTE_HANDLED,
|
||||
MSG_TYPE_QUOTE_SUBMITTED, MSG_TYPE_SYSTEM,
|
||||
} from '@/api/message'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const list = computed(() => messageStore.list)
|
||||
|
||||
/** 类型 → 图标与配色(沿用「橘子汽水」淡色轮换,不新增色系) */
|
||||
const TYPE_META: Record<string, { icon: string, bg: string, color: string }> = {
|
||||
[MSG_TYPE_ORDER_DELIVERED]: { icon: 'wallet', bg: '#FFF4D6', color: '#B57F00' },
|
||||
[MSG_TYPE_QUOTE_SUBMITTED]: { icon: 'edit-outline', bg: '#DFF7F0', color: '#0FA385' },
|
||||
[MSG_TYPE_QUOTE_HANDLED]: { icon: 'money-circle', bg: '#FFF1E8', color: '#E8531A' },
|
||||
[MSG_TYPE_FEEDBACK_REPLIED]: { icon: 'chat', bg: '#E3F2FF', color: '#1677D9' },
|
||||
[MSG_TYPE_SYSTEM]: { icon: 'notification', bg: '#ECE2FF', color: '#5B2E9E' },
|
||||
}
|
||||
|
||||
function metaOf(type: string) {
|
||||
return TYPE_META[type] || TYPE_META[MSG_TYPE_SYSTEM]
|
||||
}
|
||||
|
||||
onShow(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await messageStore.fetchList(20)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
// 列表已到达即全部置已读(清首页/我的的徽标);失败静默,下次进入重试
|
||||
messageStore.markAllRead()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onTapMessage(m: MessageItem) {
|
||||
// 先清这一条(幂等),有链接则跳转
|
||||
if (!m.is_read)
|
||||
messageStore.markRead(m.id)
|
||||
if (m.link)
|
||||
uni.navigateTo({ url: m.link })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
消息中心
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<view v-if="loading && !list.length" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!list.length" class="tb-empty">
|
||||
<wd-icon name="notification" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
还没有消息
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
会员开通、报价进度、反馈回复等通知都会出现在这里
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="m in list"
|
||||
:key="m.id"
|
||||
class="msg tb-card tb-elev-1 press"
|
||||
hover-class="press-active"
|
||||
@tap="onTapMessage(m)"
|
||||
>
|
||||
<view class="msg__icon" :style="{ background: metaOf(m.type).bg }">
|
||||
<wd-icon :name="metaOf(m.type).icon" size="36rpx" :color="metaOf(m.type).color" />
|
||||
</view>
|
||||
<view class="msg__body">
|
||||
<view class="msg__head">
|
||||
<text class="msg__title" :class="{ 'msg__title--unread': !m.is_read }">
|
||||
{{ m.title }}
|
||||
</text>
|
||||
<text class="msg__time">
|
||||
{{ m.created_at }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="msg__content">
|
||||
{{ m.content }}
|
||||
</text>
|
||||
<view v-if="m.link" class="msg__link">
|
||||
<text>查看详情</text>
|
||||
<wd-icon name="arrow-right" size="22rpx" color="#fb4e14" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!m.is_read" class="msg__dot" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.msg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.msg__icon {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), 0 4rpx 10rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.msg__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.msg__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.msg__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg__title--unread {
|
||||
font-weight: 800;
|
||||
}
|
||||
.msg__time {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.msg__content {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
align-self: flex-start;
|
||||
font-size: 22rpx;
|
||||
color: #fb4e14;
|
||||
}
|
||||
/* 未读圆点:右上角橙色带光晕 */
|
||||
.msg__dot {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #ff4d1f;
|
||||
box-shadow: 0 0 0 4rpx rgba(255, 107, 53, 0.18);
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -16,9 +16,14 @@ import { NOTE_REMIND_TMPL_ID } from '@/config/features'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const id = ref(0)
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
@@ -29,8 +34,40 @@ const pinned = ref(false)
|
||||
const remindValue = ref<number | string>('')
|
||||
/** 加载时的原始提醒时间串,用于判断「本次是否重设了提醒」(决定是否申请授权) */
|
||||
const originalRemindAt = ref('')
|
||||
/** 允许选择的最小提醒时间(过去时间不可选,R6-9) */
|
||||
const minRemindDate = Date.now()
|
||||
/** 允许选择的最小提醒时间(过去时间不可选,R6-9);打开选择器时刷新为当下 */
|
||||
const minRemindDate = ref(Date.now())
|
||||
/** 选择器弹层 */
|
||||
const pickerOpen = ref(false)
|
||||
/** 弹层内的草稿值(确认后才写回 remindValue) */
|
||||
const remindDraft = ref<number>(Date.now())
|
||||
|
||||
/** 行内展示:YYYY-MM-DD HH:mm(不带秒) */
|
||||
const remindDisplay = computed(() => {
|
||||
const v = remindValue.value
|
||||
if (v === '' || v === null || v === undefined)
|
||||
return ''
|
||||
const ms = typeof v === 'number' ? v : parseDateTime(String(v))
|
||||
if (ms <= 0)
|
||||
return ''
|
||||
const d = new Date(ms)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
})
|
||||
|
||||
function openRemindPicker() {
|
||||
remindDraft.value = typeof remindValue.value === 'number' && remindValue.value > 0 ? remindValue.value : Date.now()
|
||||
minRemindDate.value = Date.now()
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
function confirmRemind() {
|
||||
remindValue.value = remindDraft.value
|
||||
pickerOpen.value = false
|
||||
}
|
||||
|
||||
function clearRemind() {
|
||||
remindValue.value = ''
|
||||
pickerOpen.value = false
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -156,11 +193,8 @@ function requestSubscribe(): Promise<boolean> {
|
||||
async function onSave() {
|
||||
if (submitting.value || notFound.value)
|
||||
return
|
||||
// 标题可留空:后端自动命名为「未命名笔记 YYYY-MM-DD hh:mm:ss」
|
||||
const t = title.value.trim()
|
||||
if (!t) {
|
||||
uni.showToast({ title: '请填写标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const payloadRemind = currentRemindPayload()
|
||||
// 「重设了提醒」= 本次提醒值非空且与加载时不同 → 需要重新授权
|
||||
@@ -298,7 +332,7 @@ function goBack() {
|
||||
<input
|
||||
v-model="title"
|
||||
class="input"
|
||||
placeholder="给笔记起个名字"
|
||||
placeholder="给笔记起个名字(留空自动命名)"
|
||||
placeholder-class="ph"
|
||||
:maxlength="128"
|
||||
>
|
||||
@@ -362,14 +396,27 @@ function goBack() {
|
||||
|
||||
<view class="divider" />
|
||||
|
||||
<wd-datetime-picker
|
||||
v-model="remindValue"
|
||||
type="datetime"
|
||||
label="定时提醒"
|
||||
placeholder="未设置"
|
||||
:clearable="!!remindValue"
|
||||
:min-date="minRemindDate"
|
||||
/>
|
||||
<!-- 定时提醒:自绘行 + 页面根级弹层(wot 自带 cell 是不透明白底且其弹层
|
||||
会被玻璃卡的 backdrop-filter 捕获为包含块,滚轮被裁切,故绕开) -->
|
||||
<view class="row press" hover-class="press-active" @tap="openRemindPicker">
|
||||
<view class="row__left">
|
||||
<text class="row__title">
|
||||
定时提醒
|
||||
</text>
|
||||
<text class="row__desc">
|
||||
到点通过微信订阅消息提醒你
|
||||
</text>
|
||||
</view>
|
||||
<view class="remind-value">
|
||||
<text v-if="remindDisplay" class="remind-value__text">
|
||||
{{ remindDisplay }}
|
||||
</text>
|
||||
<text v-else class="remind-value__ph">
|
||||
未设置
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="24rpx" color="#C9A88E" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="row__hint">
|
||||
设置后将申请一次微信订阅消息授权(一次授权对应一次通知);未授权时提醒仅应用内可见。
|
||||
</text>
|
||||
@@ -393,6 +440,44 @@ function goBack() {
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<!-- 定时提醒选择弹层:必须挂在页面根级 —— 放进玻璃卡内时 backdrop-filter
|
||||
会成为 fixed 弹层的包含块,弹层被钳制在卡内且滚轮被裁切 -->
|
||||
<wd-popup
|
||||
v-model="pickerOpen"
|
||||
position="bottom"
|
||||
custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;"
|
||||
>
|
||||
<view class="rp">
|
||||
<view class="rp__grip" />
|
||||
<view class="rp__bar">
|
||||
<text class="rp__btn rp__btn--ghost" @tap="pickerOpen = false">
|
||||
取消
|
||||
</text>
|
||||
<text class="rp__title">
|
||||
定时提醒
|
||||
</text>
|
||||
<text class="rp__btn rp__btn--ok" @tap="confirmRemind">
|
||||
完成
|
||||
</text>
|
||||
</view>
|
||||
<wd-datetime-picker-view
|
||||
v-model="remindDraft"
|
||||
type="datetime"
|
||||
:min-date="minRemindDate"
|
||||
/>
|
||||
<view
|
||||
v-if="remindValue"
|
||||
class="rp__clear press"
|
||||
hover-class="press-active"
|
||||
@tap="clearRemind"
|
||||
>
|
||||
清除提醒
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -549,6 +634,72 @@ function goBack() {
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
/* 定时提醒行的值区 */
|
||||
.remind-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
max-width: 62%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.remind-value__text {
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
color: #e8531a;
|
||||
}
|
||||
.remind-value__ph {
|
||||
font-size: 27rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
|
||||
/* 定时提醒选择弹层(页面根级 bottom sheet,风格对齐「我的」页抽屉) */
|
||||
.rp {
|
||||
background: #fffdfb;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 20rpx 32rpx calc(24rpx + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.rp__grip {
|
||||
align-self: center;
|
||||
width: 72rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffe3d3;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.rp__bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 6rpx 0 10rpx;
|
||||
}
|
||||
.rp__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.rp__btn {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
padding: 10rpx 8rpx;
|
||||
}
|
||||
.rp__btn--ghost {
|
||||
justify-self: start;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.rp__btn--ok {
|
||||
justify-self: end;
|
||||
color: #ff6b35;
|
||||
}
|
||||
.rp__clear {
|
||||
margin-top: 12rpx;
|
||||
padding: 20rpx 0 6rpx;
|
||||
border-top: 1rpx solid #ffe9dd;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.acts {
|
||||
display: flex;
|
||||
|
||||
@@ -23,10 +23,15 @@ import {
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
/** 列表本地缓存键(离线只读兜底;后端为权威源,清缓存后重新拉取即可) */
|
||||
const CACHE_KEY = 'note_list_cache'
|
||||
const PAGE_SIZE = 20
|
||||
@@ -382,6 +387,8 @@ function remindClass(status: number): string {
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
321
src/pkg-service/detail.vue
Normal file
321
src/pkg-service/detail.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 服务详情页(分包 pkg-service)。
|
||||
*
|
||||
* 职责:封面 + 定价 + 简介 + Markdown 详细说明(解析为 rich-text 节点,主题内联样式)。
|
||||
* 设计要点:
|
||||
* - `content_md` 由 `utils/md-parser.ts` 解析;MP 端 rich-text 过滤 class,全部样式内联;
|
||||
* - MD 内图片相对路径经 BASE_URL 拼接(封面 cover_url 后端已拼好,直接用);
|
||||
* - 底部「咨询客服」:MP 用 button open-type="contact"(微信客服会话);H5 降级为提示。
|
||||
*/
|
||||
import { apiServiceDetail, servicePriceText, type ServiceDetail } from '@/api/service'
|
||||
import { BASE_URL } from '@/config/env'
|
||||
import { parseMarkdown, type MdNode } from '@/utils/md-parser'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const id = ref(0)
|
||||
const loading = ref(false)
|
||||
const notFound = ref(false)
|
||||
const detail = ref<ServiceDetail | null>(null)
|
||||
|
||||
/** MD → rich-text 节点(详情加载后重算) */
|
||||
const mdNodes = computed<MdNode[]>(() => {
|
||||
const md = detail.value?.content_md || ''
|
||||
return parseMarkdown(md, {
|
||||
resolveUrl: u => (/^https?:\/\//.test(u) ? u : `${BASE_URL}${u.startsWith('/') ? '' : '/'}${u}`),
|
||||
})
|
||||
})
|
||||
|
||||
const priceText = computed(() =>
|
||||
detail.value ? servicePriceText(detail.value.price_cents, detail.value.price_note) : '',
|
||||
)
|
||||
|
||||
onLoad((options) => {
|
||||
const raw = options && options.id ? Number(options.id) : 0
|
||||
id.value = Number.isFinite(raw) && raw > 0 ? raw : 0
|
||||
if (id.value > 0)
|
||||
loadDetail()
|
||||
else notFound.value = true
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await apiServiceDetail(id.value)
|
||||
}
|
||||
catch {
|
||||
notFound.value = true
|
||||
uni.showToast({ title: '服务不存在或已下架', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onConsult() {
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: '请在微信小程序内联系客服', icon: 'none' })
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
服务详情
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tb-body">
|
||||
<view v-if="loading" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="notFound || !detail" class="tb-empty">
|
||||
<wd-icon name="warn-bold" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
服务不存在或已下架
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 封面 -->
|
||||
<view v-if="detail.cover_url" class="cover-card tb-card tb-elev-2">
|
||||
<image class="cover-card__img" :src="detail.cover_url" mode="widthFix" />
|
||||
</view>
|
||||
|
||||
<!-- 概要 -->
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="head">
|
||||
<view class="head__cat">
|
||||
{{ detail.category_name }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="head__title">
|
||||
{{ detail.title }}
|
||||
</text>
|
||||
<view class="head__price-row">
|
||||
<text class="head__price">
|
||||
{{ priceText }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="detail.summary" class="head__summary">
|
||||
{{ detail.summary }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 详细说明(Markdown 解析渲染) -->
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
详细说明
|
||||
</text>
|
||||
</view>
|
||||
<rich-text v-if="mdNodes.length" class="md" :nodes="mdNodes" />
|
||||
<view v-else class="md-empty">
|
||||
<text>暂无详细说明</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 底部咨询条 -->
|
||||
<view v-if="detail && !loading && !notFound" class="buy">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="buy__btn press" hover-class="press-active" open-type="contact">
|
||||
咨询客服
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<view class="buy__btn press" hover-class="press-active" @tap="onConsult">
|
||||
咨询客服
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
/* 底部咨询条悬浮:给内容留出底部空间 */
|
||||
padding-bottom: calc(150rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 26rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* 封面卡 */
|
||||
.cover-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.cover-card__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 概要 */
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.head__cat {
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffd4b8;
|
||||
font-size: 21rpx;
|
||||
color: #e8531a;
|
||||
}
|
||||
.head__title {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.head__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.head__price {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.head__summary {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid #ffe9dd;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.7;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
/* 小节标题 */
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.sec__bar {
|
||||
width: 8rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #ff9f4d, #ff6b35);
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
|
||||
.md-empty {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
|
||||
/* 底部咨询条 */
|
||||
.buy {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 249, 242, 0.92);
|
||||
backdrop-filter: blur(24rpx) saturate(1.6);
|
||||
border-top: 1rpx solid rgba(255, 227, 211, 0.8);
|
||||
}
|
||||
.buy__btn {
|
||||
flex: 1;
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
/* button 重置(MP 端 button 自带边框与行高) */
|
||||
.buy__btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
1061
src/pkg-service/home.vue
Normal file
1061
src/pkg-service/home.vue
Normal file
File diff suppressed because it is too large
Load Diff
348
src/pkg-service/list.vue
Normal file
348
src/pkg-service/list.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 软件开发服务列表页(分包 pkg-service)。
|
||||
*
|
||||
* 职责:四系列分类筛选(网站 / 小程序 / App / 桌面)+ 服务卡片列表 + 分页加载 + 空态。
|
||||
* 数据全部来自后端(/goods/list,仅上架商品),封面为后端拼好的绝对地址直接展示。
|
||||
*/
|
||||
import { apiServiceList, SERVICE_CATEGORY_TABS, servicePriceText, type ServiceItem } from '@/api/service'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<ServiceItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const category = ref(0)
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function applyData(list: ServiceItem[], reset: boolean) {
|
||||
items.value = reset ? list : items.value.concat(list)
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (loading.value)
|
||||
return
|
||||
if (reset)
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await apiServiceList({
|
||||
category: category.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
total.value = data.total
|
||||
applyData(data.list || [], reset)
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一 toast;保底清空态 */
|
||||
if (reset)
|
||||
items.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickCategory(v: number) {
|
||||
if (category.value === v)
|
||||
return
|
||||
category.value = v
|
||||
load(true)
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
|
||||
onReachBottom(() => {
|
||||
if (loading.value || !hasMore.value)
|
||||
return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goDetail(id: number) {
|
||||
uni.navigateTo({ url: `/pkg-service/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
软件开发服务
|
||||
</text>
|
||||
</view>
|
||||
<text class="nav__sub">
|
||||
网站 · 小程序 · App · 桌面程序,定制开发
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="tb-body">
|
||||
<!-- 分类筛选 -->
|
||||
<scroll-view scroll-x class="catbar" :show-scrollbar="false">
|
||||
<view class="catbar__inner">
|
||||
<view
|
||||
v-for="t in SERVICE_CATEGORY_TABS"
|
||||
:key="t.value"
|
||||
class="cat press"
|
||||
:class="{ 'cat--on': category === t.value }"
|
||||
hover-class="press-active"
|
||||
@tap="pickCategory(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 列表 -->
|
||||
<view v-if="loading && !items.length" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
<view v-else-if="!items.length" class="tb-empty">
|
||||
<wd-icon name="computer" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
该系列暂无服务
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
试试切换其他系列看看
|
||||
</text>
|
||||
</view>
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="g in items"
|
||||
:key="g.id"
|
||||
class="goods tb-card tb-elev-1 press"
|
||||
hover-class="press-active"
|
||||
@tap="goDetail(g.id)"
|
||||
>
|
||||
<image
|
||||
v-if="g.cover_url"
|
||||
class="goods__cover"
|
||||
:src="g.cover_url"
|
||||
mode="aspectFill"
|
||||
:lazy-load="true"
|
||||
/>
|
||||
<view v-else class="goods__cover goods__cover--ph">
|
||||
<wd-icon name="computer" size="52rpx" color="#E8A87C" />
|
||||
</view>
|
||||
<view class="goods__info">
|
||||
<view class="goods__cat">
|
||||
{{ g.category_name }}
|
||||
</view>
|
||||
<text class="goods__title">
|
||||
{{ g.title }}
|
||||
</text>
|
||||
<text class="goods__summary">
|
||||
{{ g.summary || '点击查看详细说明' }}
|
||||
</text>
|
||||
<view class="goods__foot">
|
||||
<text class="goods__price">
|
||||
{{ servicePriceText(g.price_cents, g.price_note) }}
|
||||
</text>
|
||||
<view class="goods__more press" hover-class="press-active">
|
||||
查看详情
|
||||
<wd-icon name="arrow-right" size="22rpx" color="#fb4e14" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="more">
|
||||
<text v-if="loading">
|
||||
加载中…
|
||||
</text>
|
||||
<text v-else-if="hasMore" class="press" hover-class="press-active" @tap="load(false)">
|
||||
加载更多
|
||||
</text>
|
||||
<text v-else>
|
||||
没有更多了
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.nav__sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
padding-left: 84rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
/* 分类筛选条 */
|
||||
.catbar {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.catbar__inner {
|
||||
display: inline-flex;
|
||||
gap: 14rpx;
|
||||
padding: 4rpx 2rpx;
|
||||
}
|
||||
.cat {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1rpx solid #ffe3d3;
|
||||
font-size: 25rpx;
|
||||
color: #9a7b65;
|
||||
transition: all 0.2s var(--tb-ease);
|
||||
}
|
||||
.cat--on {
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.26);
|
||||
}
|
||||
|
||||
/* 服务卡片 */
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.goods {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
.goods__cover {
|
||||
flex-shrink: 0;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff3e9;
|
||||
}
|
||||
.goods__cover--ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx dashed #ffd4b8;
|
||||
}
|
||||
.goods__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.goods__cat {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
font-size: 20rpx;
|
||||
color: #e8531a;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.goods__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.goods__summary {
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
color: #9a7b65;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.goods__foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.goods__price {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.goods__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
font-size: 23rpx;
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-top: 8rpx;
|
||||
text-align: center;
|
||||
font-size: 23rpx;
|
||||
color: #c39a80;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
273
src/pkg-service/project-bind.vue
Normal file
273
src/pkg-service/project-bind.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目绑定页(分包 pkg-service)。
|
||||
*
|
||||
* 用户扫「项目绑定小程序码」进入本页(scene = p<项目id><令牌>),解析令牌后调
|
||||
* /service/project/bind 绑定到当前账号;也支持手动输入绑定码(后台复制发人的兜底)。
|
||||
* 绑定成功 → 跳项目详情。
|
||||
*/
|
||||
import { apiProjectBind, parseProjectScene } from '@/api/project'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
const binding = ref(false)
|
||||
const token = ref('')
|
||||
const errorMsg = ref('')
|
||||
const done = ref(false)
|
||||
|
||||
/** 从 scene / query 提取令牌;scene 为空时展示手动输入框 */
|
||||
const autoToken = ref('')
|
||||
|
||||
onLoad(async (options) => {
|
||||
// MP 码值在 scene;H5 / 手动分享可能直接带 token
|
||||
autoToken.value = parseProjectScene(options?.scene as string) || (options?.token as string) || ''
|
||||
if (autoToken.value)
|
||||
await bind(autoToken.value)
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => token.value.trim().length >= 6 && !binding.value)
|
||||
|
||||
async function bind(t: string) {
|
||||
if (binding.value)
|
||||
return
|
||||
binding.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await apiProjectBind(t.trim())
|
||||
done.value = true
|
||||
uni.showToast({ title: '绑定成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: `/pkg-service/project-detail?id=${res.project_id}` })
|
||||
}, 800)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error)?.message || '绑定失败,请稍后重试'
|
||||
}
|
||||
finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value)
|
||||
return
|
||||
bind(token.value)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
绑定开发项目
|
||||
</text>
|
||||
</view>
|
||||
<text class="nav__sub">
|
||||
扫码绑定后可随时查看进度与交付物
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="tb-body">
|
||||
<!-- 绑定成功态(瞬间,随后跳详情) -->
|
||||
<view v-if="done" class="tb-empty">
|
||||
<wd-icon name="check-circle-filled" size="60rpx" color="#0FA385" />
|
||||
<text class="tb-empty__title">
|
||||
绑定成功
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
正在进入项目详情…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="card__icon">
|
||||
<wd-icon name="link" size="52rpx" color="#FF6B35" />
|
||||
</view>
|
||||
<text class="card__title">
|
||||
{{ autoToken ? '正在为你绑定项目…' : '输入绑定码' }}
|
||||
</text>
|
||||
<text class="card__desc">
|
||||
{{ autoToken
|
||||
? '请稍候,不要离开本页'
|
||||
: '打开微信扫一扫开发者提供的项目绑定码即可;也可以直接输入 10 位绑定码' }}
|
||||
</text>
|
||||
|
||||
<input
|
||||
v-if="!autoToken"
|
||||
v-model="token"
|
||||
class="card__input"
|
||||
placeholder="10 位绑定码"
|
||||
placeholder-class="ph"
|
||||
:maxlength="16"
|
||||
confirm-type="done"
|
||||
@confirm="submit"
|
||||
>
|
||||
<view v-else class="card__token">
|
||||
{{ autoToken }}
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="!autoToken"
|
||||
class="bind-btn press"
|
||||
:class="{ 'bind-btn--off': !canSubmit }"
|
||||
hover-class="press-active"
|
||||
@tap="submit"
|
||||
>
|
||||
{{ binding ? '绑定中…' : '绑定项目' }}
|
||||
</view>
|
||||
|
||||
<text v-if="errorMsg" class="card__error">
|
||||
{{ errorMsg }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="tip">
|
||||
绑定后可在「我的 → 我的项目」随时查看开发阶段、小节点完成情况和原型 / UI 设计稿。
|
||||
</view>
|
||||
</template>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.nav__sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
padding-left: 84rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 40rpx 36rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card__icon {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff1e8;
|
||||
border: 2rpx solid #ffd4b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.card__desc {
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
text-align: center;
|
||||
}
|
||||
.card__input {
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
font-size: 32rpx;
|
||||
letter-spacing: 0.18em;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card__token {
|
||||
padding: 20rpx 40rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #fff4d6;
|
||||
border: 2rpx solid #ffe3a8;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
color: #b57f00;
|
||||
}
|
||||
.ph {
|
||||
color: #c39a80;
|
||||
}
|
||||
.bind-btn {
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
.bind-btn--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.card__error {
|
||||
font-size: 23rpx;
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: #b3907c;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
765
src/pkg-service/project-detail.vue
Normal file
765
src/pkg-service/project-detail.vue
Normal file
@@ -0,0 +1,765 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目详情页(分包 pkg-service)。
|
||||
*
|
||||
* 用户视角只读 + 一个主动作。统一节奏:每节一张卡(概要 / 分期付款 / 开发进度 / 维护服务),
|
||||
* 卡内「橙条 + 小节标题」,行内虚线分隔;确认验收时会顺带请求维护费提醒的订阅消息授权
|
||||
* (模板未配置则静默跳过)。
|
||||
*/
|
||||
import type { ProjectDetail } from '@/api/project'
|
||||
import { apiProjectAccept, apiProjectDetail, projectStatusTone, stageStatusTone } from '@/api/project'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { PROJECT_REMIND_TMPL_ID } from '@/config/features'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const id = ref(0)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const detail = ref<ProjectDetail | null>(null)
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const d = detail.value
|
||||
if (!d || !d.stage_total)
|
||||
return 0
|
||||
return Math.round((d.stage_done / d.stage_total) * 100)
|
||||
})
|
||||
|
||||
/** 金额展示:分 → 元(整数元不带小数) */
|
||||
function yuanText(cents: number): string {
|
||||
if (!cents)
|
||||
return '0'
|
||||
return cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2)
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const raw = Number(options?.id || 0)
|
||||
id.value = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0
|
||||
if (id.value > 0)
|
||||
load()
|
||||
else notFound.value = true
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await apiProjectDetail(id.value)
|
||||
}
|
||||
catch {
|
||||
notFound.value = true
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function previewImage(files: { file_url: string }[], current: string) {
|
||||
const urls = files.map(f => f.file_url)
|
||||
if (urls.length)
|
||||
uni.previewImage({ urls, current: current || urls[0] })
|
||||
}
|
||||
|
||||
// ===== 确认验收:进入维护期 =====
|
||||
const accepting = ref(false)
|
||||
|
||||
function onAccept() {
|
||||
if (accepting.value)
|
||||
return
|
||||
const maintain = detail.value?.maintain
|
||||
const tail = maintain && maintain.source_only === 1
|
||||
? '该项目为仅交付源码(无售后维护),确认后完成交付。'
|
||||
: `确认后将从今天开始计算维护期${maintain && maintain.free_years > 0 ? `,免费维护至 ${maintain.paid_until || '(按模板)'}` : ''}。`
|
||||
uni.showModal({
|
||||
title: '确认验收?',
|
||||
content: `请确认项目成果符合预期。${tail}`,
|
||||
confirmText: '确认验收',
|
||||
success: (res) => {
|
||||
if (res.confirm)
|
||||
doAccept()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function doAccept() {
|
||||
accepting.value = true
|
||||
try {
|
||||
// 订阅授权:模板未配置(features.ts 为空串)时静默跳过,不打扰用户
|
||||
// #ifdef MP-WEIXIN
|
||||
if (PROJECT_REMIND_TMPL_ID) {
|
||||
try {
|
||||
await uni.requestSubscribeMessage({ tmplIds: [PROJECT_REMIND_TMPL_ID] })
|
||||
}
|
||||
catch {
|
||||
/* 用户拒绝授权不阻断验收 */
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
await apiProjectAccept(id.value)
|
||||
uni.showToast({ title: '已确认验收', icon: 'success' })
|
||||
await load()
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层提示 */
|
||||
}
|
||||
finally {
|
||||
accepting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
项目详情
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<view v-if="loading" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="notFound || !detail" class="tb-empty">
|
||||
<wd-icon name="warn-bold" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
项目不存在或未绑定到当前账号
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- ① 项目概要 -->
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="head__top">
|
||||
<text class="head__title">
|
||||
{{ detail.title }}
|
||||
</text>
|
||||
<view class="chip" :style="{ color: projectStatusTone(detail.status), background: `${projectStatusTone(detail.status)}1a` }">
|
||||
{{ detail.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="detail.description" class="head__desc">
|
||||
{{ detail.description }}
|
||||
</text>
|
||||
<view class="head__progress">
|
||||
<view class="head__track">
|
||||
<view class="head__fill" :style="{ width: `${progressPct}%` }" />
|
||||
</view>
|
||||
<text class="head__pct">
|
||||
{{ progressPct }}%
|
||||
</text>
|
||||
</view>
|
||||
<view class="head__meta">
|
||||
<view class="head__meta-item">
|
||||
<text class="head__meta-label">
|
||||
项目金额
|
||||
</text>
|
||||
<text class="head__meta-value head__meta-value--price">
|
||||
{{ detail.price_cents ? `¥${yuanText(detail.price_cents)}` : '面议' }}{{ detail.price_note }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="detail.due_note" class="head__meta-item head__meta-item--div">
|
||||
<text class="head__meta-label">
|
||||
交付周期
|
||||
</text>
|
||||
<text class="head__meta-value">
|
||||
{{ detail.due_note }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ② 分期付款 -->
|
||||
<view v-if="detail.pays.length" class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
分期付款
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(pay, i) in detail.pays"
|
||||
:key="pay.id"
|
||||
class="pay"
|
||||
:class="{ 'pay--last': i === detail.pays.length - 1, 'pay--done': pay.status === 1 }"
|
||||
>
|
||||
<view class="pay__seq" :class="{ 'pay__seq--done': pay.status === 1 }">
|
||||
{{ pay.seq }}
|
||||
</view>
|
||||
<view class="pay__info">
|
||||
<text class="pay__trigger">
|
||||
{{ pay.trigger_text }}
|
||||
</text>
|
||||
<text class="pay__percent">
|
||||
占 {{ pay.percent }}%{{ pay.paid_at ? ` · ${pay.paid_at} 到账` : '' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="pay__right">
|
||||
<text class="pay__amount">
|
||||
¥{{ yuanText(pay.amount_cents) }}
|
||||
</text>
|
||||
<view class="chip" :class="pay.status === 1 ? 'chip--done' : 'chip--pending'">
|
||||
{{ pay.status === 1 ? '已到账' : '待支付' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ③ 开发进度 -->
|
||||
<view v-if="detail.stages.length" class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
开发进度
|
||||
</text>
|
||||
<text class="sec__extra">
|
||||
{{ detail.stage_done }}/{{ detail.stage_total }} 阶段
|
||||
</text>
|
||||
</view>
|
||||
<view class="timeline">
|
||||
<view
|
||||
v-for="(s, i) in detail.stages"
|
||||
:key="s.id"
|
||||
class="tl-item"
|
||||
:class="{ 'tl-item--done': s.status === 2, 'tl-item--doing': s.status === 1 }"
|
||||
>
|
||||
<view class="tl-rail">
|
||||
<view class="tl-dot">
|
||||
<wd-icon v-if="s.status === 2" name="check" size="24rpx" color="#ffffff" />
|
||||
<text v-else class="tl-dot__idx">
|
||||
{{ i + 1 }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="i < detail.stages.length - 1" class="tl-line" />
|
||||
</view>
|
||||
<view class="tl-body">
|
||||
<view class="tl-head">
|
||||
<text class="tl-title" :class="{ 'tl-title--pending': s.status === 0 }">
|
||||
{{ s.title }}
|
||||
</text>
|
||||
<view class="chip" :style="{ color: stageStatusTone(s.status), background: `${stageStatusTone(s.status)}1a` }">
|
||||
{{ s.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="s.nodes.length" class="tl-nodes">
|
||||
<view v-for="n in s.nodes" :key="n.id" class="tl-node">
|
||||
<wd-icon
|
||||
:name="n.is_done ? 'check-circle-filled' : 'circle'"
|
||||
size="26rpx"
|
||||
:color="n.is_done ? '#0fa385' : '#d9c3b2'"
|
||||
/>
|
||||
<text class="tl-node__title" :class="{ 'tl-node__title--done': n.is_done }">
|
||||
{{ n.title }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="s.files.length" class="tl-files">
|
||||
<text class="tl-files__label">
|
||||
交付物({{ s.files.length }})
|
||||
</text>
|
||||
<view class="tl-grid">
|
||||
<image
|
||||
v-for="f in s.files"
|
||||
:key="f.id"
|
||||
class="tl-grid__img"
|
||||
:src="f.file_url"
|
||||
mode="aspectFill"
|
||||
:lazy-load="true"
|
||||
@tap="previewImage(s.files, f.file_url)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ④ 维护服务 -->
|
||||
<view v-if="detail.maintain" class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
维护服务
|
||||
</text>
|
||||
<view v-if="detail.maintain.accepted === 1 && detail.maintain.source_only !== 1" class="chip chip--done">
|
||||
维护期内
|
||||
</view>
|
||||
</view>
|
||||
<view class="mt">
|
||||
<view class="mt__row">
|
||||
<text class="mt__label">
|
||||
维护模板
|
||||
</text>
|
||||
<text class="mt__value">
|
||||
{{ detail.maintain.plan_name }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="mt__row">
|
||||
<text class="mt__label">
|
||||
{{ detail.maintain.source_only === 1 ? '售后范围' : '维护费' }}
|
||||
</text>
|
||||
<text class="mt__value">
|
||||
{{ detail.maintain.source_only === 1
|
||||
? '仅交付源码,无售后维护'
|
||||
: `${detail.maintain.free_years > 0 ? `交付后 ${detail.maintain.free_years} 年免费` : '无免费期'}${detail.maintain.yearly_fee_cents > 0 ? `,之后 ¥${yuanText(detail.maintain.yearly_fee_cents)}/年` : ''}` }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="detail.maintain.accepted === 1" class="mt__row">
|
||||
<text class="mt__label">
|
||||
验收时间
|
||||
</text>
|
||||
<text class="mt__value">
|
||||
{{ detail.maintain.accepted_at }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="detail.maintain.accepted === 1 && detail.maintain.source_only !== 1" class="mt__row">
|
||||
<text class="mt__label">
|
||||
维护费已缴至
|
||||
</text>
|
||||
<text class="mt__value mt__value--hi">
|
||||
{{ detail.maintain.paid_until || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.can_accept === 1" class="accept">
|
||||
<view
|
||||
class="accept__btn press"
|
||||
:class="{ 'accept__btn--off': accepting }"
|
||||
hover-class="press-active"
|
||||
@tap="onAccept"
|
||||
>
|
||||
{{ accepting ? '提交中…' : '确认验收' }}
|
||||
</view>
|
||||
<text class="accept__tip">
|
||||
确认后将从今天起算维护期(开发者可调整),到期前会提醒你缴纳维护费
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
/* 统一节奏:每节一张卡,内边距 / 卡距全页一致 */
|
||||
.body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.sec__bar {
|
||||
width: 8rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #ff9f4d, #ff6b35);
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.sec__extra {
|
||||
margin-left: auto;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
/* 状态小胶囊(全页统一) */
|
||||
.chip {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
padding: 5rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.chip--pending {
|
||||
color: #b57f00;
|
||||
background: #fff4d6;
|
||||
}
|
||||
.chip--done {
|
||||
color: #0fa385;
|
||||
background: #dff7f0;
|
||||
}
|
||||
|
||||
/* ① 概要 */
|
||||
.head__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.head__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.head__desc {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.head__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.head__track {
|
||||
flex: 1;
|
||||
height: 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffe3d3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.head__fill {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #ff9f4d, #fb4e14);
|
||||
}
|
||||
.head__pct {
|
||||
flex-shrink: 0;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
color: #fb4e14;
|
||||
min-width: 64rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.head__meta {
|
||||
display: flex;
|
||||
margin-top: 24rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx dashed #ffe3d3;
|
||||
}
|
||||
.head__meta-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.head__meta-item--div {
|
||||
border-left: 1rpx dashed #ffe3d3;
|
||||
padding-left: 32rpx;
|
||||
}
|
||||
.head__meta-label {
|
||||
font-size: 20rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.head__meta-value {
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.head__meta-value--price {
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
/* ② 分期付款:卡内平铺行 + 虚线分隔 */
|
||||
.pay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx dashed #ffe3d3;
|
||||
}
|
||||
.pay--last {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
.pay__seq {
|
||||
flex-shrink: 0;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff1e8;
|
||||
border: 2rpx solid #ffd4b8;
|
||||
color: #e8531a;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pay__seq--done {
|
||||
background: #dff7f0;
|
||||
border-color: #b8ecdd;
|
||||
color: #0fa385;
|
||||
}
|
||||
.pay__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
.pay__trigger {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.pay__percent {
|
||||
font-size: 20rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.pay__right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.pay__amount {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
/* ③ 开发进度:整段时间轴收进一张卡 */
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tl-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding-bottom: 32rpx;
|
||||
}
|
||||
.tl-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tl-rail {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 48rpx;
|
||||
}
|
||||
.tl-dot {
|
||||
flex-shrink: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #e2d3c6;
|
||||
color: #b3907c;
|
||||
font-size: 21rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 10rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.tl-item--doing .tl-dot {
|
||||
background: #fff4d6;
|
||||
border-color: #f5c56b;
|
||||
color: #b57f00;
|
||||
box-shadow: 0 0 0 6rpx rgba(245, 197, 107, 0.18);
|
||||
}
|
||||
.tl-item--done .tl-dot {
|
||||
background: linear-gradient(140deg, #3ecfb2, #0fa385);
|
||||
border-color: transparent;
|
||||
}
|
||||
.tl-line {
|
||||
flex: 1;
|
||||
width: 4rpx;
|
||||
min-height: 24rpx;
|
||||
margin: 8rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: #ffe3d3;
|
||||
}
|
||||
.tl-item--done .tl-line {
|
||||
background: #9be3d2;
|
||||
}
|
||||
.tl-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
.tl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.tl-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.tl-title--pending {
|
||||
color: #b3907c;
|
||||
}
|
||||
.tl-nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.tl-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.tl-node__title {
|
||||
font-size: 23rpx;
|
||||
color: #6b5a4d;
|
||||
}
|
||||
.tl-node__title--done {
|
||||
color: #9a7b65;
|
||||
}
|
||||
.tl-files {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.tl-files__label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
color: #b3907c;
|
||||
}
|
||||
.tl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.tl-grid__img {
|
||||
width: 100%;
|
||||
height: 150rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #fff3e9;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
}
|
||||
|
||||
/* ④ 维护服务 */
|
||||
.mt__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx dashed #ffe3d3;
|
||||
}
|
||||
.mt__row:last-of-type {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
.mt__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.mt__value {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #3d2a1e;
|
||||
text-align: right;
|
||||
}
|
||||
.mt__value--hi {
|
||||
color: #fb4e14;
|
||||
font-weight: 800;
|
||||
}
|
||||
.accept {
|
||||
margin-top: 26rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.accept__btn {
|
||||
height: 84rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
.accept__btn--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.accept__tip {
|
||||
font-size: 20rpx;
|
||||
line-height: 1.6;
|
||||
color: #b3907c;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
286
src/pkg-service/projects.vue
Normal file
286
src/pkg-service/projects.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的项目列表(分包 pkg-service)。
|
||||
*
|
||||
* 展示当前用户绑定的开发项目:状态、阶段进度、金额、交付周期;点击进项目详情。
|
||||
* 入口:「我的」金刚区 / 站内信消息。
|
||||
*/
|
||||
import type { ProjectItem } from '@/api/project'
|
||||
import { apiProjectMine, projectStatusTone } from '@/api/project'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const list = ref<ProjectItem[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiProjectMine()
|
||||
list.value = res.list || []
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goDetail(id: number) {
|
||||
uni.navigateTo({ url: `/pkg-service/project-detail?id=${id}` })
|
||||
}
|
||||
|
||||
function goBind() {
|
||||
uni.navigateTo({ url: '/pkg-service/project-bind' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
我的项目
|
||||
</text>
|
||||
</view>
|
||||
<text class="nav__sub">
|
||||
开发进度 · 小节点 · 原型与设计稿
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="tb-body">
|
||||
<view v-if="loading && !list.length" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!list.length" class="tb-empty">
|
||||
<wd-icon name="app" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
还没有绑定的项目
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
用微信扫一扫开发者提供的项目绑定码,即可在这里看到开发进度
|
||||
</text>
|
||||
<view class="empty-cta press" hover-class="press-active" @tap="goBind">
|
||||
输入绑定码绑定
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="p in list"
|
||||
:key="p.id"
|
||||
class="item tb-card tb-elev-1 press"
|
||||
hover-class="press-active"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="item__top">
|
||||
<text class="item__title">
|
||||
{{ p.title }}
|
||||
</text>
|
||||
<view class="item__status" :style="{ color: projectStatusTone(p.status), background: `${projectStatusTone(p.status)}1a` }">
|
||||
{{ p.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="p.description" class="item__desc">
|
||||
{{ p.description }}
|
||||
</text>
|
||||
|
||||
<!-- 阶段进度条 -->
|
||||
<view v-if="p.stage_total" class="progress">
|
||||
<view class="progress__track">
|
||||
<view
|
||||
class="progress__fill"
|
||||
:style="{ width: `${Math.round((p.stage_done / p.stage_total) * 100)}%` }"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress__text">
|
||||
{{ p.stage_done }}/{{ p.stage_total }} 阶段
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="item__meta">
|
||||
<text v-if="p.due_note" class="item__meta-item">
|
||||
交付周期 {{ p.due_note }}
|
||||
</text>
|
||||
<text v-if="p.price_cents || p.price_note" class="item__meta-item item__meta-item--price">
|
||||
{{ p.price_cents ? `¥${(p.price_cents / 100).toFixed(p.price_cents % 100 ? 2 : 0)}` : '' }}{{ p.price_note }}
|
||||
</text>
|
||||
<text class="item__time">
|
||||
{{ p.updated_at }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.nav__sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
padding-left: 84rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.item {
|
||||
padding: 26rpx;
|
||||
}
|
||||
.item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.item__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item__status {
|
||||
flex-shrink: 0;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.item__desc {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
margin-top: 12rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.progress__track {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffe3d3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress__fill {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #ff9f4d, #fb4e14);
|
||||
transition: width 0.4s var(--tb-ease);
|
||||
}
|
||||
.progress__text {
|
||||
flex-shrink: 0;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
.item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.item__meta-item {
|
||||
font-size: 21rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.item__meta-item--price {
|
||||
color: #fb4e14;
|
||||
font-weight: 700;
|
||||
}
|
||||
.item__time {
|
||||
margin-left: auto;
|
||||
font-size: 20rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
370
src/pkg-service/quote.vue
Normal file
370
src/pkg-service/quote.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 报价申请页(分包 pkg-service)。
|
||||
*
|
||||
* 用户填写需求申请报价:联系方式(必填)+ 预算档位 + 需求描述(必填)。
|
||||
* 带 work_id 时展示关联作品卡片(申请做一个类似的);提交成功后服务端发
|
||||
* 站内信回执,本页跳「我的报价」。
|
||||
*/
|
||||
import { apiQuoteSubmit, apiServiceWorkDetail, type WorkDetail } from '@/api/service'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const workId = ref(0)
|
||||
const work = ref<WorkDetail | null>(null)
|
||||
|
||||
const contact = ref('')
|
||||
const requirement = ref('')
|
||||
const budget = ref('')
|
||||
|
||||
/** 预算档位(文案直接随申请单落库,后台看板可直接读) */
|
||||
const BUDGET_OPTIONS = ['1 千以内', '1 千 - 5 千', '5 千 - 1 万', '1 万以上', '还没想好']
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 需求描述最少 10 字:避免「做个 App」这种没法报价的单子 */
|
||||
const requirementOk = computed(() => requirement.value.trim().length >= 10)
|
||||
|
||||
onLoad((options) => {
|
||||
const raw = Number(options?.work_id || 0)
|
||||
workId.value = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0
|
||||
if (workId.value > 0) {
|
||||
apiServiceWorkDetail(workId.value)
|
||||
.then((d) => {
|
||||
work.value = d
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function pickBudget(v: string) {
|
||||
budget.value = budget.value === v ? '' : v
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value)
|
||||
return
|
||||
if (!contact.value.trim()) {
|
||||
uni.showToast({ title: '请填写联系方式', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!requirementOk.value) {
|
||||
uni.showToast({ title: '需求描述至少 10 个字', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await apiQuoteSubmit({
|
||||
work_id: workId.value,
|
||||
contact: contact.value.trim(),
|
||||
requirement: requirement.value.trim(),
|
||||
budget: budget.value,
|
||||
})
|
||||
uni.showToast({ title: '已提交,我们会尽快回复', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pkg-service/quotes' })
|
||||
}, 900)
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层提示 */
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
申请报价
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<!-- 关联作品卡(从作品详情带过来) -->
|
||||
<view v-if="work" class="work-link tb-card tb-elev-1">
|
||||
<image v-if="work.cover_url" class="work-link__cover" :src="work.cover_url" mode="aspectFill" />
|
||||
<view class="work-link__body">
|
||||
<text class="work-link__tip">
|
||||
想做一个类似的
|
||||
</text>
|
||||
<text class="work-link__title">
|
||||
{{ work.title }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form tb-card tb-elev-1">
|
||||
<view class="field">
|
||||
<text class="field__label">
|
||||
联系方式 <text class="field__req">*</text>
|
||||
</text>
|
||||
<input
|
||||
v-model="contact"
|
||||
class="field__input"
|
||||
placeholder="微信号 / 手机号,方便我们联系你"
|
||||
placeholder-class="ph"
|
||||
:maxlength="64"
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field__label">
|
||||
预算范围
|
||||
</text>
|
||||
<view class="chips">
|
||||
<view
|
||||
v-for="opt in BUDGET_OPTIONS"
|
||||
:key="opt"
|
||||
class="chip press"
|
||||
:class="{ 'chip--on': budget === opt }"
|
||||
hover-class="press-active"
|
||||
@tap="pickBudget(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field__label">
|
||||
需求描述 <text class="field__req">*</text>
|
||||
</text>
|
||||
<textarea
|
||||
v-model="requirement"
|
||||
class="field__textarea"
|
||||
placeholder="想做什么产品?大概有哪些功能?给谁用?写得越具体,报价越准(至少 10 个字)"
|
||||
placeholder-class="ph"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
<text class="field__count">
|
||||
{{ requirement.length }}/1000
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tip">
|
||||
提交后我们会尽快评估并通过站内信回复报价结果,也可以在「我的报价」中随时查看进度。
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<!-- 底部提交条 -->
|
||||
<view class="bar">
|
||||
<view
|
||||
class="bar__btn press"
|
||||
:class="{ 'bar__btn--off': submitting }"
|
||||
hover-class="press-active"
|
||||
@tap="submit"
|
||||
>
|
||||
{{ submitting ? '提交中…' : '提交需求' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(190rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.work-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.work-link__cover {
|
||||
flex-shrink: 0;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #fff3e9;
|
||||
}
|
||||
.work-link__body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.work-link__tip {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
font-size: 20rpx;
|
||||
color: #e8531a;
|
||||
}
|
||||
.work-link__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 28rpx 26rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30rpx;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.field__label {
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.field__req {
|
||||
color: #fb4e14;
|
||||
}
|
||||
.field__input {
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
font-size: 27rpx;
|
||||
color: #3d2a1e;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field__textarea {
|
||||
width: 100%;
|
||||
min-height: 220rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.6;
|
||||
color: #3d2a1e;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field__count {
|
||||
align-self: flex-end;
|
||||
font-size: 21rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.ph {
|
||||
color: #c39a80;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.chip {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
font-size: 24rpx;
|
||||
color: #9a7b65;
|
||||
transition: all 0.2s var(--tb-ease);
|
||||
}
|
||||
.chip--on {
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.26);
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 20rpx;
|
||||
padding: 0 12rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: #b3907c;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 249, 242, 0.92);
|
||||
backdrop-filter: blur(24rpx) saturate(1.6);
|
||||
border-top: 1rpx solid rgba(255, 227, 211, 0.8);
|
||||
}
|
||||
.bar__btn {
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
.bar__btn--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
289
src/pkg-service/quotes.vue
Normal file
289
src/pkg-service/quotes.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的报价列表页(分包 pkg-service)。
|
||||
*
|
||||
* 展示报价申请进度:待处理 / 已报价(金额+说明)/ 已成交 / 已关闭。
|
||||
* 处理结果由后台写入并经站内信通知,此页是结果的常驻查看入口。
|
||||
*/
|
||||
import { apiQuoteMine, type QuoteItem } from '@/api/service'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { quoteStatusTone } from '@/api/service'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const loading = ref(true)
|
||||
const list = ref<QuoteItem[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiQuoteMine(20)
|
||||
list.value = res.list || []
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goQuote() {
|
||||
uni.navigateTo({ url: '/pkg-service/quote' })
|
||||
}
|
||||
|
||||
function priceText(item: QuoteItem) {
|
||||
if (!item.quote_cents)
|
||||
return ''
|
||||
const yuan = item.quote_cents % 100 === 0 ? String(item.quote_cents / 100) : (item.quote_cents / 100).toFixed(2)
|
||||
return `¥${yuan}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
我的报价
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<view v-if="loading && !list.length" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!list.length" class="tb-empty">
|
||||
<wd-icon name="edit" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
还没有报价申请
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
把你的想法告诉我们,很快就能收到报价
|
||||
</text>
|
||||
<view class="empty-cta press" hover-class="press-active" @tap="goQuote">
|
||||
去申请报价
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view v-for="q in list" :key="q.id" class="item tb-card tb-elev-1">
|
||||
<view class="item__top">
|
||||
<text class="item__title">
|
||||
{{ q.work_title || '定制开发需求' }}
|
||||
</text>
|
||||
<view class="item__status" :style="{ color: quoteStatusTone(q.status), background: `${quoteStatusTone(q.status)}1a` }">
|
||||
{{ q.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="item__req">
|
||||
{{ q.requirement }}
|
||||
</text>
|
||||
<view v-if="q.budget" class="item__row">
|
||||
<text class="item__label">
|
||||
预算
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ q.budget }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 处理结果(已报价 / 已成交 / 已关闭) -->
|
||||
<view v-if="q.quote_cents || q.quote_note" class="result">
|
||||
<view v-if="q.quote_cents" class="result__row">
|
||||
<text class="result__label">
|
||||
报价
|
||||
</text>
|
||||
<text class="result__price">
|
||||
{{ priceText(q) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="q.quote_note" class="result__note">
|
||||
{{ q.quote_note }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="item__foot">
|
||||
<text class="item__time">
|
||||
{{ q.created_at }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.item {
|
||||
padding: 26rpx;
|
||||
}
|
||||
.item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.item__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item__status {
|
||||
flex-shrink: 0;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.item__req {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.item__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.item__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.item__value {
|
||||
font-size: 23rpx;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #fff4d6;
|
||||
border: 1rpx solid #ffe3a8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.result__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.result__label {
|
||||
font-size: 22rpx;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.result__price {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
}
|
||||
.result__note {
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.item__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
.item__time {
|
||||
font-size: 21rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
381
src/pkg-service/work-detail.vue
Normal file
381
src/pkg-service/work-detail.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品详情页(分包 pkg-service)。
|
||||
*
|
||||
* 封面 + 报价卡(查看报价)+ 图集网格(点击预览)+ Markdown 详情 + 底部
|
||||
* 「申请做一个类似的」直达报价表单(带 work_id);MP 端保留微信客服会话入口。
|
||||
*/
|
||||
import { apiServiceWorkDetail, servicePriceText, type WorkDetail } from '@/api/service'
|
||||
import { BASE_URL } from '@/config/env'
|
||||
import { parseMarkdown, type MdNode } from '@/utils/md-parser'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const navPad = navPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
const id = ref(0)
|
||||
const loading = ref(false)
|
||||
const notFound = ref(false)
|
||||
const detail = ref<WorkDetail | null>(null)
|
||||
|
||||
const mdNodes = computed<MdNode[]>(() => {
|
||||
const md = detail.value?.content_md || ''
|
||||
return parseMarkdown(md, {
|
||||
resolveUrl: u => (/^https?:\/\//.test(u) ? u : `${BASE_URL}${u.startsWith('/') ? '' : '/'}${u}`),
|
||||
})
|
||||
})
|
||||
|
||||
const priceText = computed(() =>
|
||||
detail.value ? servicePriceText(detail.value.price_cents, detail.value.price_note) : '面议',
|
||||
)
|
||||
|
||||
onLoad((options) => {
|
||||
const raw = options && options.id ? Number(options.id) : 0
|
||||
id.value = Number.isFinite(raw) && raw > 0 ? raw : 0
|
||||
if (id.value > 0)
|
||||
loadDetail()
|
||||
else notFound.value = true
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await apiServiceWorkDetail(id.value)
|
||||
}
|
||||
catch {
|
||||
notFound.value = true
|
||||
uni.showToast({ title: '作品不存在或已下架', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goQuote() {
|
||||
uni.navigateTo({ url: `/pkg-service/quote?work_id=${id.value}` })
|
||||
}
|
||||
|
||||
function previewImage(urls: string[], current: string) {
|
||||
if (!urls.length)
|
||||
return
|
||||
uni.previewImage({ urls, current: current || urls[0] })
|
||||
}
|
||||
|
||||
function onConsult() {
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: '请在微信小程序内联系客服', icon: 'none' })
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
作品详情
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<view v-if="loading" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="notFound || !detail" class="tb-empty">
|
||||
<wd-icon name="warn-bold" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
作品不存在或已下架
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 封面 -->
|
||||
<view v-if="detail.cover_url" class="cover-card tb-card tb-elev-2">
|
||||
<image
|
||||
class="cover-card__img"
|
||||
:src="detail.cover_url"
|
||||
mode="widthFix"
|
||||
@tap="previewImage([detail.cover_url], detail.cover_url)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 概要 + 报价 -->
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="head__cat">
|
||||
{{ detail.category_name }}
|
||||
</view>
|
||||
<text class="head__title">
|
||||
{{ detail.title }}
|
||||
</text>
|
||||
<view class="quote">
|
||||
<view class="quote__label">
|
||||
参考报价
|
||||
</view>
|
||||
<text class="quote__price">
|
||||
{{ priceText }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="detail.summary" class="head__summary">
|
||||
{{ detail.summary }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 图集 -->
|
||||
<view v-if="detail.images.length" class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
案例图集
|
||||
</text>
|
||||
</view>
|
||||
<view class="gallery">
|
||||
<image
|
||||
v-for="(u, i) in detail.images"
|
||||
:key="i"
|
||||
class="gallery__img"
|
||||
:src="u"
|
||||
mode="aspectFill"
|
||||
:lazy-load="true"
|
||||
@tap="previewImage(detail.images, u)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详细说明 -->
|
||||
<view class="card tb-card tb-elev-1">
|
||||
<view class="sec">
|
||||
<view class="sec__bar" />
|
||||
<text class="sec__title">
|
||||
案例说明
|
||||
</text>
|
||||
</view>
|
||||
<rich-text v-if="mdNodes.length" class="md" :nodes="mdNodes" />
|
||||
<view v-else class="md-empty">
|
||||
<text>暂无详细说明</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作条:申请做一个类似的 + 咨询客服 -->
|
||||
<view v-if="detail && !loading && !notFound" class="buy">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="buy__ghost press" hover-class="press-active" open-type="contact">
|
||||
咨询客服
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
<view class="buy__btn press" hover-class="press-active" @tap="goQuote">
|
||||
申请做一个类似的
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(170rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 26rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.cover-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.cover-card__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head__cat {
|
||||
display: inline-block;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
border: 1rpx solid #ffd4b8;
|
||||
font-size: 21rpx;
|
||||
color: #e8531a;
|
||||
}
|
||||
.head__title {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 报价卡:金色渐变底,突出「查看报价」 */
|
||||
.quote {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 100%);
|
||||
}
|
||||
.quote__label {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.quote__price {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
}
|
||||
.head__summary {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid #ffe9dd;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.7;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.sec__bar {
|
||||
width: 8rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #ff9f4d, #ff6b35);
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14rpx;
|
||||
}
|
||||
.gallery__img {
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #fff3e9;
|
||||
}
|
||||
.md-empty {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
|
||||
/* 底部操作条 */
|
||||
.buy {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 249, 242, 0.92);
|
||||
backdrop-filter: blur(24rpx) saturate(1.6);
|
||||
border-top: 1rpx solid rgba(255, 227, 211, 0.8);
|
||||
}
|
||||
.buy__ghost {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0 40rpx;
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
color: #9a7b65;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.buy__ghost::after {
|
||||
border: none;
|
||||
}
|
||||
.buy__btn {
|
||||
flex: 1;
|
||||
height: 92rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
338
src/pkg-service/works.vue
Normal file
338
src/pkg-service/works.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品案例列表页(分包 pkg-service)。
|
||||
*
|
||||
* 四系列分类筛选 + 作品卡片列表 + 分页加载 + 空态;点击进作品详情(可查看报价、申请类似)。
|
||||
*/
|
||||
import { apiServiceWorks, SERVICE_CATEGORY_TABS, servicePriceText, type WorkItem } from '@/api/service'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const heroPad = heroPadTop()
|
||||
const scrolled = usePageScrolled()
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<WorkItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const category = ref(0)
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (loading.value)
|
||||
return
|
||||
if (reset)
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await apiServiceWorks({
|
||||
category: category.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
total.value = data.total
|
||||
items.value = reset ? (data.list || []) : items.value.concat(data.list || [])
|
||||
}
|
||||
catch {
|
||||
if (reset)
|
||||
items.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickCategory(v: number) {
|
||||
if (category.value === v)
|
||||
return
|
||||
category.value = v
|
||||
load(true)
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
|
||||
onReachBottom(() => {
|
||||
if (loading.value || !hasMore.value)
|
||||
return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goDetail(id: number) {
|
||||
uni.navigateTo({ url: `/pkg-service/work-detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
作品案例
|
||||
</text>
|
||||
</view>
|
||||
<text class="nav__sub">
|
||||
真实交付案例 · 点开可看参考报价
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="tb-body">
|
||||
<!-- 分类筛选 -->
|
||||
<scroll-view scroll-x class="catbar" :show-scrollbar="false">
|
||||
<view class="catbar__inner">
|
||||
<view
|
||||
v-for="t in SERVICE_CATEGORY_TABS"
|
||||
:key="t.value"
|
||||
class="cat press"
|
||||
:class="{ 'cat--on': category === t.value }"
|
||||
hover-class="press-active"
|
||||
@tap="pickCategory(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 列表 -->
|
||||
<view v-if="loading && !items.length" class="tb-empty">
|
||||
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
正在加载…
|
||||
</text>
|
||||
</view>
|
||||
<view v-else-if="!items.length" class="tb-empty">
|
||||
<wd-icon name="goods" size="56rpx" color="#E8A87C" />
|
||||
<text class="tb-empty__title">
|
||||
该系列暂无作品
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
试试切换其他系列看看
|
||||
</text>
|
||||
</view>
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="w in items"
|
||||
:key="w.id"
|
||||
class="work tb-card tb-elev-1 press"
|
||||
hover-class="press-active"
|
||||
@tap="goDetail(w.id)"
|
||||
>
|
||||
<image
|
||||
v-if="w.cover_url"
|
||||
class="work__cover"
|
||||
:src="w.cover_url"
|
||||
mode="aspectFill"
|
||||
:lazy-load="true"
|
||||
/>
|
||||
<view v-else class="work__cover work__cover--ph">
|
||||
<wd-icon name="goods" size="52rpx" color="#E8A87C" />
|
||||
</view>
|
||||
<view class="work__info">
|
||||
<view class="work__cat">
|
||||
{{ w.category_name }}
|
||||
</view>
|
||||
<text class="work__title">
|
||||
{{ w.title }}
|
||||
</text>
|
||||
<text class="work__summary">
|
||||
{{ w.summary || '点击查看案例详情' }}
|
||||
</text>
|
||||
<view class="work__foot">
|
||||
<text class="work__price">
|
||||
{{ w.price_cents ? `参考价 ${servicePriceText(w.price_cents, w.price_note)}` : '报价面议' }}
|
||||
</text>
|
||||
<view class="work__more press" hover-class="press-active">
|
||||
查看详情
|
||||
<wd-icon name="arrow-right" size="22rpx" color="#fb4e14" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="more">
|
||||
<text v-if="loading">
|
||||
加载中…
|
||||
</text>
|
||||
<text v-else-if="hasMore" class="press" hover-class="press-active" @tap="load(false)">
|
||||
加载更多
|
||||
</text>
|
||||
<text v-else>
|
||||
没有更多了
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.nav__sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
padding-left: 84rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.catbar {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.catbar__inner {
|
||||
display: inline-flex;
|
||||
gap: 14rpx;
|
||||
padding: 4rpx 2rpx;
|
||||
}
|
||||
.cat {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1rpx solid #ffe3d3;
|
||||
font-size: 25rpx;
|
||||
color: #9a7b65;
|
||||
transition: all 0.2s var(--tb-ease);
|
||||
}
|
||||
.cat--on {
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.26);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.work {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
.work__cover {
|
||||
flex-shrink: 0;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff3e9;
|
||||
}
|
||||
.work__cover--ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx dashed #ffd4b8;
|
||||
}
|
||||
.work__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.work__cat {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff1e8;
|
||||
font-size: 20rpx;
|
||||
color: #e8531a;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.work__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.work__summary {
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
color: #9a7b65;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.work__foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.work__price {
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.work__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
font-size: 23rpx;
|
||||
color: #fb4e14;
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-top: 8rpx;
|
||||
text-align: center;
|
||||
font-size: 23rpx;
|
||||
color: #c39a80;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
.foot {
|
||||
height: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -11,10 +11,15 @@ import { ICONS } from './icons'
|
||||
import SecOne from './sections/SecOne.vue'
|
||||
import SecThree from './sections/SecThree.vue'
|
||||
import SecTwo from './sections/SecTwo.vue'
|
||||
import BackFab from '@/components/BackFab.vue'
|
||||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||||
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
/** 滚过阈值后显示吸顶返回按钮(R3) */
|
||||
const scrolled = usePageScrolled()
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
@@ -66,6 +71,8 @@ function goBack() {
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<BackFab :show="scrolled" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
42
src/stores/feedback.ts
Normal file
42
src/stores/feedback.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiFeedbackMine, apiFeedbackRead, type FeedbackMineItem } from '@/api/user'
|
||||
|
||||
/**
|
||||
* 反馈(我的反馈 + 官方回复未读红点)。
|
||||
* unread 是「我的」页 意见反馈 入口红点的数据源;
|
||||
* 抽屉打开时 fetchMine 拉列表(此刻未读标记仍在,供「新回复」标签展示),随后 markRead 清零。
|
||||
*/
|
||||
export const useFeedbackStore = defineStore('feedback', () => {
|
||||
const list = ref<FeedbackMineItem[]>([])
|
||||
const unread = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 拉取我的反馈(含未读数)。仅登录后调用。 */
|
||||
async function fetchMine(limit = 20) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiFeedbackMine(limit)
|
||||
list.value = res?.list || []
|
||||
unread.value = res?.unread || 0
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部回复置为已读(清红点)。无未读时不发请求。 */
|
||||
async function markRead() {
|
||||
if (unread.value <= 0)
|
||||
return
|
||||
try {
|
||||
await apiFeedbackRead()
|
||||
unread.value = 0
|
||||
}
|
||||
catch {
|
||||
// 静默失败:红点下次进页会重新拉取
|
||||
}
|
||||
}
|
||||
|
||||
return { list, unread, loading, fetchMine, markRead }
|
||||
})
|
||||
74
src/stores/message.ts
Normal file
74
src/stores/message.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiMessageList, apiMessageRead, apiMessageUnread, type MessageItem } from '@/api/message'
|
||||
|
||||
/**
|
||||
* 站内信(未读徽标 + 消息中心列表)。
|
||||
*
|
||||
* unread 是首页工作台消息 icon 与「我的」页金刚区徽标的数据源;
|
||||
* 首页/我的 load 时 fire-and-forget 拉一次,进入消息中心读完列表后清零。
|
||||
*/
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const list = ref<MessageItem[]>([])
|
||||
const unread = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 拉取消息列表(含未读数)。仅登录后调用,失败静默(徽标下次再刷新)。 */
|
||||
async function fetchList(limit = 20) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiMessageList(limit)
|
||||
list.value = res?.list || []
|
||||
unread.value = res?.unread || 0
|
||||
return res
|
||||
}
|
||||
catch {
|
||||
// 静默:401 等已由 request 层处理,这里不重复打扰
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅刷新未读数(更轻,用于切 pane 刷新徽标) */
|
||||
async function fetchUnread() {
|
||||
try {
|
||||
const res = await apiMessageUnread()
|
||||
unread.value = res?.count || 0
|
||||
}
|
||||
catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部置已读(清徽标)。无未读时不发请求。 */
|
||||
async function markAllRead() {
|
||||
if (unread.value <= 0)
|
||||
return
|
||||
try {
|
||||
await apiMessageRead(0)
|
||||
unread.value = 0
|
||||
list.value = list.value.map(m => ({ ...m, is_read: 1 }))
|
||||
}
|
||||
catch {
|
||||
// 静默失败:红点下次进页会重新拉取
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条置已读(点消息跳转时) */
|
||||
async function markRead(id: number) {
|
||||
try {
|
||||
await apiMessageRead(id)
|
||||
const hit = list.value.find(m => m.id === id)
|
||||
if (hit && !hit.is_read) {
|
||||
hit.is_read = 1
|
||||
unread.value = Math.max(0, unread.value - 1)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
return { list, unread, loading, fetchList, fetchUnread, markAllRead, markRead }
|
||||
})
|
||||
@@ -88,6 +88,11 @@ $tb-radius: 30rpx; // 卡片圆角
|
||||
$tb-shadow: 0 8rpx 24rpx rgba(107, 47, 15, 0.06); // 卡片软阴影
|
||||
$tb-shadow-float: 0 12rpx 40rpx rgba(107, 47, 15, 0.18); // 悬浮元素阴影
|
||||
$tb-spring: cubic-bezier(0.34, 1.56, 0.64, 1); // spring 动效曲线
|
||||
/* 液态玻璃 token(与 App.vue 的 --tb-glass-* 同源;色相不变,只加透明度/模糊层) */
|
||||
$tb-glass-bg: rgba(255, 255, 255, 0.55);
|
||||
$tb-glass-bg-strong: rgba(255, 255, 255, 0.68);
|
||||
$tb-glass-line: rgba(255, 255, 255, 0.65);
|
||||
$tb-glass-blur: 20rpx;
|
||||
/* 撞色点缀 */
|
||||
$tb-mint: #3ecfb2;
|
||||
$tb-lemon: #ffc53d;
|
||||
|
||||
295
src/utils/md-parser.ts
Normal file
295
src/utils/md-parser.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 轻量 Markdown → rich-text nodes 解析器(软件开发服务详情页专用)。
|
||||
*
|
||||
* 为什么不用 mp-html / towxml:项目零 markdown 依赖,本场景只需要后台运营写的
|
||||
* 「说明文档」子集,自研解析零依赖、主题内联可控。
|
||||
*
|
||||
* MP 端 `<rich-text>` 不能用 v-html,且节点上的 class 会被过滤 →
|
||||
* 全部样式走 attrs.style 内联(px 单位,两端一致);配色为「橘子汽水」token 的
|
||||
* 十六进制定值(--tb-* 的运行时值,配色铁律:只动质感不动色相)。
|
||||
*
|
||||
* 支持:h1–h4(更深按 h4)、段落、有序/无序列表、引用块、行内/块级代码、
|
||||
* 加粗/斜体/行内代码/链接(渲染为主题色文字,不跳转)、图片(相对路径经 resolveUrl
|
||||
* 拼接)、分割线、简单表格。未知语法按普通段落处理(fail-open)。
|
||||
*/
|
||||
|
||||
/** 富文本节点(uni-app rich-text nodes 元素) */
|
||||
export interface MdNode {
|
||||
name: string
|
||||
attrs?: Record<string, string>
|
||||
children?: MdChild[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本子节点:rich-text nodes 规范要求文本必须是 { type:'text', text } 对象,
|
||||
* 裸字符串在微信端与 uni-app H5 端都会被静默丢弃(渲染成空占位)。
|
||||
*/
|
||||
export interface MdTextNode {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type MdChild = MdNode | MdTextNode
|
||||
|
||||
/** 「橘子汽水」主题定值(与 App.vue 的 --tb-* 一致;rich-text 内联样式不能用 var()) */
|
||||
const T = {
|
||||
text: '#3d2a1e',
|
||||
text2: '#9a7b65',
|
||||
primary: '#ff6b35',
|
||||
primaryDeep: '#fb4e14',
|
||||
codeText: '#c2410c',
|
||||
codeBg: '#fff1e8',
|
||||
border: '#ffe3d3',
|
||||
quoteBg: '#fff9f2',
|
||||
} as const
|
||||
|
||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
||||
|
||||
interface Ctx {
|
||||
resolveUrl: (url: string) => string
|
||||
}
|
||||
|
||||
/** 造规范文本节点(裸字符串会被 rich-text 丢弃) */
|
||||
function t(text: string): MdTextNode {
|
||||
return { type: 'text', text }
|
||||
}
|
||||
|
||||
function escInline(text: string): string {
|
||||
return text.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/** 行内解析:**粗** / *斜* / `码` / [文](链) → 节点数组 */
|
||||
function parseInline(src: string, ctx: Ctx): MdChild[] {
|
||||
const out: MdChild[] = []
|
||||
// 依序匹配:行内代码 > 粗体 > 斜体 > 链接
|
||||
const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\s][^*]*\*)|(\[[^\]]+\]\([^)\s]+\))/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(src))) {
|
||||
if (m.index > last)
|
||||
out.push(t(escInline(src.slice(last, m.index))))
|
||||
const token = m[0]
|
||||
if (token.startsWith('`')) {
|
||||
out.push({ name: 'code', attrs: { style: `background:${T.codeBg};color:${T.codeText};padding:1px 6px;border-radius:4px;font-family:${MONO};font-size:13px` }, children: [t(token.slice(1, -1))] })
|
||||
} else if (token.startsWith('**')) {
|
||||
out.push({ name: 'strong', attrs: { style: `font-weight:700;color:${T.text}` }, children: [t(token.slice(2, -2))] })
|
||||
} else if (token.startsWith('*')) {
|
||||
out.push({ name: 'em', attrs: { style: 'font-style:italic' }, children: [t(token.slice(1, -1))] })
|
||||
} else {
|
||||
// 链接:rich-text 内 a 标签在 MP 不可点,渲染为主题色文字 + 下划线暗示
|
||||
const mm = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
|
||||
if (mm)
|
||||
out.push({ name: 'span', attrs: { style: `color:${T.primaryDeep};text-decoration:underline` }, children: [t(mm[1])] })
|
||||
else
|
||||
out.push(t(escInline(token)))
|
||||
}
|
||||
last = m.index + token.length
|
||||
}
|
||||
if (last < src.length)
|
||||
out.push(t(escInline(src.slice(last))))
|
||||
return out
|
||||
}
|
||||
|
||||
function heading(level: number, text: string, ctx: Ctx): MdNode {
|
||||
const sizes: Record<number, string> = { 1: '22px', 2: '19px', 3: '17px', 4: '16px' }
|
||||
const weights: Record<number, string> = { 1: '800', 2: '700', 3: '700', 4: '600' }
|
||||
const name = `h${Math.min(Math.max(level, 1), 4)}`
|
||||
return {
|
||||
name,
|
||||
attrs: {
|
||||
style: `font-size:${sizes[Math.min(level, 4)]};font-weight:${weights[Math.min(level, 4)]};color:${level <= 2 ? T.text : T.text};margin:${level <= 2 ? '18px 0 10px' : '14px 0 8px'};line-height:1.4;${level <= 2 ? `padding-left:10px;border-left:4px solid ${T.primary};` : ''}`,
|
||||
},
|
||||
children: parseInline(text, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
function paragraph(text: string, ctx: Ctx): MdNode {
|
||||
return { name: 'p', attrs: { style: `margin:8px 0;font-size:15px;line-height:1.8;color:${T.text}` }, children: parseInline(text, ctx) }
|
||||
}
|
||||
|
||||
function listItem(text: string, marker: string, ctx: Ctx): MdNode {
|
||||
return {
|
||||
name: 'li',
|
||||
attrs: { style: `margin:4px 0;font-size:15px;line-height:1.7;color:${T.text};list-style:none` },
|
||||
children: [{ name: 'span', attrs: { style: `color:${T.primary};font-weight:700;margin-right:6px` }, children: [t(marker)] }, ...parseInline(text, ctx)],
|
||||
}
|
||||
}
|
||||
|
||||
function codeBlock(lines: string[]): MdNode {
|
||||
return {
|
||||
name: 'pre',
|
||||
attrs: {
|
||||
style: `background:${T.codeBg};border:1px solid ${T.border};border-radius:12px;padding:12px 14px;margin:10px 0;overflow-x:auto`,
|
||||
},
|
||||
children: [{
|
||||
name: 'code',
|
||||
attrs: { style: `font-family:${MONO};font-size:13px;color:${T.codeText};white-space:pre;line-height:1.6` },
|
||||
children: [t(lines.join('\n'))],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function quote(lines: string[], ctx: Ctx): MdNode {
|
||||
const inner: MdChild[] = []
|
||||
for (const line of lines) inner.push(paragraph(line, ctx))
|
||||
return {
|
||||
name: 'blockquote',
|
||||
attrs: { style: `margin:10px 0;padding:8px 12px;background:${T.quoteBg};border-left:4px solid #ffd4b8;border-radius:0 10px 10px 0` },
|
||||
children: inner,
|
||||
}
|
||||
}
|
||||
|
||||
function imageNode(alt: string, url: string, ctx: Ctx): MdNode {
|
||||
return {
|
||||
name: 'img',
|
||||
attrs: {
|
||||
src: ctx.resolveUrl(url),
|
||||
alt,
|
||||
style: 'max-width:100%;width:auto;border-radius:12px;margin:10px auto;display:block',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function tableNode(rows: string[][], ctx: Ctx): MdNode {
|
||||
const cellStyle = `border:1px solid ${T.border};padding:6px 10px;font-size:13px;color:${T.text}`
|
||||
const trs: MdNode[] = rows.map((cells, ri) => ({
|
||||
name: 'tr',
|
||||
children: cells.map(c => ({
|
||||
name: ri === 0 ? 'th' : 'td',
|
||||
attrs: { style: `${cellStyle}${ri === 0 ? `;background:${T.codeBg};font-weight:700` : ''}` },
|
||||
children: parseInline(c, ctx),
|
||||
})),
|
||||
}))
|
||||
return {
|
||||
name: 'table',
|
||||
attrs: { style: `border-collapse:collapse;width:100%;margin:10px 0` },
|
||||
children: trs,
|
||||
}
|
||||
}
|
||||
|
||||
function hrNode(): MdNode {
|
||||
return { name: 'hr', attrs: { style: `border:none;border-top:1px solid ${T.border};margin:14px 0` } }
|
||||
}
|
||||
|
||||
/** 独立图片行: */
|
||||
const IMG_LINE = /^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/
|
||||
/** 表格分隔行:| --- | --- | */
|
||||
const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
|
||||
|
||||
/**
|
||||
* 解析 Markdown 文本为 rich-text nodes。
|
||||
* @param md Markdown 原文
|
||||
* @param opts.resolveUrl 图片相对路径 → 可访问 URL(如 BASE_URL 拼接)
|
||||
*/
|
||||
export function parseMarkdown(md: string, opts: { resolveUrl?: (url: string) => string } = {}): MdNode[] {
|
||||
const ctx: Ctx = { resolveUrl: opts.resolveUrl || ((u) => u) }
|
||||
const nodes: MdNode[] = []
|
||||
if (!md)
|
||||
return nodes
|
||||
|
||||
const lines = md.replace(/\r\n?/g, '\n').split('\n')
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// 空行
|
||||
if (!trimmed) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 围栏代码块
|
||||
if (trimmed.startsWith('```')) {
|
||||
const buf: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
buf.push(lines[i])
|
||||
i++
|
||||
}
|
||||
i++ // 跳过收尾 ```
|
||||
nodes.push(codeBlock(buf))
|
||||
continue
|
||||
}
|
||||
|
||||
// 标题
|
||||
const hm = /^(#{1,6})\s+(.+)$/.exec(trimmed)
|
||||
if (hm) {
|
||||
nodes.push(heading(hm[1].length, hm[2].trim(), ctx))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 分割线
|
||||
if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) {
|
||||
nodes.push(hrNode())
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 独立图片
|
||||
const im = IMG_LINE.exec(trimmed)
|
||||
if (im) {
|
||||
nodes.push(imageNode(im[1], im[2], ctx))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 表格:当前行含 | 且下一行是分隔行
|
||||
if (trimmed.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
|
||||
const rows: string[][] = [trimmed.replace(/^\||\|$/g, '').split('|').map(c => c.trim())]
|
||||
i += 2
|
||||
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
||||
rows.push(lines[i].trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()))
|
||||
i++
|
||||
}
|
||||
nodes.push(tableNode(rows, ctx))
|
||||
continue
|
||||
}
|
||||
|
||||
// 引用块(连续 > 行)
|
||||
if (trimmed.startsWith('>')) {
|
||||
const buf: string[] = [trimmed.replace(/^>\s?/, '')]
|
||||
i++
|
||||
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
||||
buf.push(lines[i].trim().replace(/^>\s?/, ''))
|
||||
i++
|
||||
}
|
||||
nodes.push(quote(buf, ctx))
|
||||
continue
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||
nodes.push(listItem(lines[i].trim().replace(/^[-*+]\s+/, ''), '•', ctx))
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (/^\d+[.)]\s+/.test(trimmed)) {
|
||||
let n = 1
|
||||
while (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) {
|
||||
nodes.push(listItem(lines[i].trim().replace(/^\d+[.)]\s+/, ''), `${n}.`, ctx))
|
||||
n++
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落(连续非空、非结构行合并为一段)
|
||||
const buf: string[] = [trimmed]
|
||||
i++
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim()
|
||||
if (!t || /^(#{1,6})\s|```|^[-*+]\s|^\d+[.)]\s|^>|!\[|^(\*{3,}|-{3,}|_{3,})$/.test(t) || (t.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1] || '')))
|
||||
break
|
||||
buf.push(t)
|
||||
i++
|
||||
}
|
||||
nodes.push(paragraph(buf.join(' '), ctx))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
19
src/utils/page-scrolled.ts
Normal file
19
src/utils/page-scrolled.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { onPageScroll } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 页面滚动监听(吸顶返回按钮 BackFab 的数据源)。
|
||||
*
|
||||
* ⚠️ 必须在**页面**的 `<script setup>` 里调用(onPageScroll 是页面级生命周期,
|
||||
* uni-app 仅在页面 setup 期间注册才生效;本 composable 依据这一点设计,
|
||||
* 组件内部不要调用)。阈值以下恒为 false,避免轻微抖动导致按钮闪烁。
|
||||
*/
|
||||
export function usePageScrolled(threshold = 96) {
|
||||
const scrolled = ref(false)
|
||||
onPageScroll((e) => {
|
||||
const v = (e?.scrollTop ?? 0) > threshold
|
||||
if (v !== scrolled.value)
|
||||
scrolled.value = v
|
||||
})
|
||||
return scrolled
|
||||
}
|
||||
@@ -27,6 +27,12 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
},
|
||||
// 服务封面 / MD 内图片 / 头像等静态资源(后端库内只存相对路径,H5 端经 BASE_URL=/api 拼不出 /static,
|
||||
// 故单独代理静态目录到同一服务;小程序端用后端下发的绝对 cover_url,不走这里)
|
||||
'/static': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user