功能、UI更新

This commit is contained in:
李琦
2026-09-30 17:03:38 +08:00
parent e06f0566b4
commit 8694fde44a
62 changed files with 7662 additions and 336 deletions

3
components.d.ts vendored
View File

@@ -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']

View File

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

View File

@@ -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
View 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] || ''
}

View File

@@ -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
View 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
View 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 })
}

View File

@@ -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 })
}
/**

View File

@@ -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
View 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 })
}

View File

@@ -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', {})
}

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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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: '楼层里的工具小卡:默认「图标在上、文字横排在下」(窄列,不含容器版式)' },
]

View File

@@ -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',
// },
]

View File

@@ -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"]
}
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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%);

View File

@@ -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
View 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>

View File

@@ -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;

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

348
src/pkg-service/list.vue Normal file
View 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>

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

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

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

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

View File

@@ -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
View 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
View 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 }
})

View File

@@ -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
View 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` } }
}
/** 独立图片行:![alt](url) */
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
}

View 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
}

View File

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