功能、UI更新

This commit is contained in:
李琦
2026-09-29 10:57:55 +08:00
parent 515f1ce405
commit e06f0566b4
149 changed files with 14066 additions and 1120 deletions

12
components.d.ts vendored
View File

@@ -7,24 +7,36 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
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']
CouponCard: typeof import('./src/components/coupon/CouponCard.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
MineDrawers: typeof import('./src/components/mine/MineDrawers.vue')['default']
PaneLibrary: typeof import('./src/components/home/PaneLibrary.vue')['default']
PaneMine: typeof import('./src/components/home/PaneMine.vue')['default']
PaneRefresher: typeof import('./src/components/home/PaneRefresher.vue')['default']
PaneWorkbench: typeof import('./src/components/home/PaneWorkbench.vue')['default']
PromoCodeInput: typeof import('./src/components/member/PromoCodeInput.vue')['default']
QuotaBar: typeof import('./src/components/vip/QuotaBar.vue')['default']
QuotaGate: typeof import('./src/components/vip/QuotaGate.vue')['default']
SeatMap: typeof import('./src/components/event/SeatMap.vue')['default']
Sticker: typeof import('./src/components/Sticker.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TabIcon: typeof import('./src/components/TabIcon.vue')['default']
ToolIdle: typeof import('./src/components/tool/ToolIdle.vue')['default']
ToolPage: typeof import('./src/components/tool/ToolPage.vue')['default']
ToolRunning: typeof import('./src/components/tool/ToolRunning.vue')['default']
ToolSliderRow: typeof import('./src/components/tool/ToolSliderRow.vue')['default']
VipCard: typeof import('./src/components/vip/VipCard.vue')['default']
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']
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']
WdSwitch: typeof import('wot-design-uni/components/wd-switch/wd-switch.vue')['default']
WdTextarea: typeof import('wot-design-uni/components/wd-textarea/wd-textarea.vue')['default']
}
}

View File

@@ -0,0 +1,71 @@
/**
* A2 自检脚本(可复现):验证「同一秒内两个同名任务 → 两个不等的 savedName」
*
* 直接加载生产代码 `src/utils/image/name-collision.ts`(零依赖纯函数),
* 由 Node 原生类型剥离执行,无需打包工具:
*
* node scripts/selfcheck-naming.mjs
*
* 结果同时打印到终端并写入 `scripts/selfcheck-naming.log`(UTF-8),
* 避免 Windows 控制台代码页把中文输出弄成乱码。
* 模拟 `core.ts#saveResult` 的去冲突步骤:
* 候选名 + 「当天历史 ∪ 本次会话已用名」集合 → dedupeName → 命中则追加 -2、-3。
*/
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { dedupeName } from '../src/utils/image/name-collision.ts'
const lines = []
let failed = 0
function assert(cond, msg) {
const line = `${cond ? 'ok ' : 'FAIL'}: ${msg}`
lines.push(line)
if (!cond)
failed++
}
// —— 核心场景:同一秒、同工具、同原图,连续产出两次 ——
const candidate = '去除背景-IMG_2043-20260918153012.png'
const taken = []
const n1 = dedupeName(candidate, taken)
taken.push(n1)
const n2 = dedupeName(candidate, taken)
taken.push(n2)
assert(n1 === candidate, `第一次产出保持干净格式:${n1}`)
assert(n2 !== n1, `两次产出文件名不相等:${n1} vs ${n2}`)
assert(n2 === '去除背景-IMG_2043-20260918153012-2.png', `第二次追加 -2:${n2}`)
// —— 边界:时间戳(14 位数字)绝不能被误判成 `-N` 计数器 ——
assert(n2.endsWith('-20260918153012-2.png'), '时间戳未被当作计数器截断')
// —— 边界:第三次继续递增 ——
const n3 = dedupeName(candidate, taken)
assert(n3 === '去除背景-IMG_2043-20260918153012-3.png', `第三次追加 -3:${n3}`)
// —— 边界:主干已带 -N 后缀时从 N+1 起,避免出现 xxx-2-2 ——
assert(dedupeName('x-2.png', ['x-2.png']) === 'x-3.png', '已带后缀时递增不叠加(-2 → -3)')
// —— 边界:无冲突时原样返回(正常路径不改名) ——
assert(dedupeName('a.png', []) === 'a.png', '无冲突原样返回')
// —— 边界:无扩展名的候选 ——
assert(dedupeName('plain', ['plain']) === 'plain-2', '无扩展名时追加 -2')
lines.push('')
lines.push(failed ? `${failed} check(s) FAILED` : 'ALL CHECKS PASSED')
const out = lines.join('\n')
// eslint-disable-next-line no-console
console.log(out)
try {
const here = dirname(fileURLToPath(import.meta.url))
writeFileSync(join(here, 'selfcheck-naming.log'), `${out}\n`, 'utf8')
}
catch {
/* 忽略:写日志失败不影响自检结论 */
}
process.exit(failed ? 1 : 0)

View File

@@ -20,6 +20,12 @@ page {
--tb-text: #3d2a1e;
--tb-text-2: #9a7b65;
--tb-border: #ffe3d3;
/* 卡片质感 token(R2-1):描边高光 + 内高光 + 三档阴影分层。色系不新增 */
--tb-card-line: rgba(255, 195, 155, 0.55);
--tb-card-hi: rgba(255, 255, 255, 0.92);
--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);
/* 撞色点缀 */
--tb-mint: #3ecfb2;
--tb-lemon: #ffc53d;
@@ -68,6 +74,30 @@ page {
opacity: 0.92;
}
/* 卡片点击热区(P3):承接 tap 的原生 view 包裹层。
撑满网格单元高度,让同排 CardTile 视觉等高;子卡 flex 撑满。
注:子卡 .ct 自带 overflow:hidden(自动最小尺寸已为 0),故 min-width:0 只需在包裹层声明一次。 */
.cttap {
display: flex;
min-width: 0;
/* 显式撑满:grid/flex 单元格里不允许出现"卡片比格子窄"的留白(P5 用户反馈②) */
width: 100%;
align-items: stretch;
}
/* 缺陷 A 修复:MP 下自定义组件宿主(如 <card-tile>)会在 `.cttap` 与 `.ct` 之间包一层,
使 `.cttap > .ct` 这个**子选择器永不匹配**(H5 单根组件就地渲染、无宿主包裹 → 只在 H5 生效,
H5 会对本 bug 假性通过)。宿主作为 flex 项若不显式撑满,主尺寸退化为 max-content
→ 卡片宽度塌成「最长 nowrap 行」内容宽(≈4 个中文字)。
⚠️ MP 端宿主撑满**不能**在这里用 `.cttap > *` 兜底 —— WXSS 不支持通配符 `*`,
真机开发者工具直接报「app.wxss(x:x): error at token '*'」,整个 app.wxss 编译失败
(uni build EXIT=0 ≠ 微信工具链可编译)。MP 宿主撑满改由 CardTile.wxss 内的
`:host` 规则承担(见 CardTile.vue <style> 首部),一处规则覆盖全部调用点。 */
.cttap > .ct {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
/* ============ 渐变头区(波浪底边,含状态栏 / 胶囊安全距离) ============ */
.tb-hero {
position: relative;
@@ -102,8 +132,38 @@ page {
.tb-card {
background: var(--tb-card, #ffffff);
border-radius: 36rpx;
border: 1rpx solid rgba(255, 195, 155, 0.5);
box-shadow: 0 3rpx 10rpx rgba(107, 47, 15, 0.04), 0 18rpx 44rpx rgba(107, 47, 15, 0.08);
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);
}
/* ============ 卡片质感分层(R2-1 / R2-2)============
三档权重:tb-elev-1(弱:副卡/宫格)→ tb-elev-2(中:发现工具/楼层卡)→ tb-elev-3(强);
另有两档专用: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);
}
.tb-elev-2 {
box-shadow: inset 0 1rpx 0 var(--tb-card-hi, rgba(255, 255, 255, 0.92)), 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);
}
/* 主卡(最常用工具):橙渐变上叠内高光 + 主色投影(对齐既有 .wb-lead 阴影升级) */
.tb-elev-primary {
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.45), 0 6rpx 16rpx rgba(251, 78, 20, 0.24),
0 18rpx 40rpx rgba(251, 78, 20, 0.3);
}
/* 会员卡:金边深炭底,内高光用暖金(对齐既有 .vcard 阴影升级) */
.tb-elev-gold {
box-shadow: inset 0 1rpx 0 rgba(255, 246, 224, 0.32), 0 18rpx 42rpx rgba(43, 26, 16, 0.34),
0 0 0 6rpx rgba(245, 197, 107, 0.08);
}
/* 图标块:内高光 + 近距离投影(.tb-tile__icon / 徽标 / 楼层图标统一) */
.tb-ico-shadow {
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), inset 0 -6rpx 12rpx rgba(255, 255, 255, 0.7),
0 6rpx 14rpx rgba(107, 47, 15, 0.1);
}
/* 分区标题:橙色圆条 + 加粗 */
@@ -185,7 +245,9 @@ page {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(107, 47, 15, 0.08);
/* 图标投影统一 token(R2-2 ④) */
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), inset 0 -6rpx 12rpx rgba(255, 255, 255, 0.7),
0 6rpx 14rpx rgba(107, 47, 15, 0.1);
}
.tb-tile__name {
font-size: 24rpx;
@@ -216,10 +278,13 @@ page {
flex-direction: column;
align-items: center;
gap: 12rpx;
margin-top: 24rpx;
border: 2rpx dashed #ffd4b8;
border-radius: 30rpx;
padding: 56rpx 0;
padding: 56rpx 40rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
box-sizing: border-box;
}
.tb-empty__title {
font-size: 30rpx;
@@ -229,6 +294,8 @@ page {
.tb-empty__sub {
font-size: 24rpx;
color: var(--tb-text-2, #9a7b65);
line-height: 1.6;
max-width: 560rpx;
}
/* ============ 动效:卡片上浮进场(随 pane 激活重放) ============ */
@@ -242,10 +309,124 @@ page {
transform: translateY(0);
}
}
/* ============ 首页 pane(三 tab 视图)============
每个 pane 组件根元素即 scroll-view;布局与进出场动画放全局,
保证「父 index.vue 下 class → 子 pane 根元素」跨组件也能命中(MP 同样生效)。 */
.pane {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* 先声明退场、后声明进场:同屏带两个类时进场动画优先 */
.pane--out {
animation: tbPaneOut 0.3s ease both;
}
/* fill 用 backwards:动画结束后 transform 归位 none,
避免永久保留层叠上下文/包含块,把 pane 内 fixed 弹层困住 */
.pane--in {
animation: tbPaneIn 0.36s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
}
.pane--back.pane--in {
animation-name: tbPaneInBack;
}
@keyframes tbPaneIn {
from {
opacity: 0;
transform: translateX(48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneInBack {
from {
opacity: 0;
transform: translateX(-48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.pane--in .tb-rise {
animation: tbRise 0.5s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
}
/* ============ 自定义下拉刷新:橘子气泡(R4-2)============
纯 CSS/SVG 自绘,无序列帧大图;下拉时随距离由 0.5× 放大到 1×,
松手后(.tb-refresh--on)三气泡循环上浮。 */
.tb-refresh {
height: 100%;
min-height: 88rpx;
/* 宽度必须自己撑满:微信自定义 refresher 的插槽宿主是**收缩宽度**容器,
view 作为其子节点宽度只按内容算 → 整列气泡+文案贴左(R5 真机反馈「不在屏幕中间」)。
min-width:100vw 兜住「宿主为收缩宽度」的情形;width:100% 兜住「宿主为整行」的情形。 */
width: 100%;
min-width: 100vw;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.tb-refresh__bubbles {
display: flex;
align-items: flex-end;
gap: 14rpx;
transform-origin: center bottom;
transition: transform 0.12s linear;
}
.tb-refresh__bubble {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: radial-gradient(circle at 34% 30%, #ffc08a 0%, #ff9f4d 46%, #ff6b35 100%);
box-shadow: inset 0 2rpx 3rpx rgba(255, 255, 255, 0.75), 0 4rpx 8rpx rgba(251, 78, 20, 0.25);
}
.tb-refresh__bubble:nth-child(2) {
width: 28rpx;
height: 28rpx;
}
.tb-refresh__bubble:nth-child(3) {
width: 18rpx;
height: 18rpx;
}
.tb-refresh--on .tb-refresh__bubble {
animation: tbBubble 0.9s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) infinite;
}
.tb-refresh--on .tb-refresh__bubble:nth-child(2) {
animation-delay: 0.12s;
}
.tb-refresh--on .tb-refresh__bubble:nth-child(3) {
animation-delay: 0.24s;
}
.tb-refresh__text {
height: 28rpx;
line-height: 28rpx;
font-size: 22rpx;
color: var(--tb-text-2, #9a7b65);
}
@keyframes tbBubble {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-8rpx) scale(1.14);
}
}
/* ============ H5:uni-input 有值时内部 input 高度塌陷的兜底 ============ */
/* #ifdef H5 */
uni-input .uni-input-wrapper {

91
src/api/coupon.ts Normal file
View File

@@ -0,0 +1,91 @@
import { get, post } from '@/api/request'
/**
* 优惠券域用户端封装(对接后端 `api/user/v1/promo.go`)。
*
* 约定(铁律 S1):响应 **snake_case**;请求沿用端点既有字段名 ——
* /coupon/validate、/coupon/usable 的入参为 **camelCase**(userCouponId / orderType / targetKey)。
*/
/** 券型:1 免单券(走支付)/ 2 展示券(仅展示) */
export const COUPON_TYPE_FREEBIE = 1
export const COUPON_TYPE_DISPLAY = 2
/** 券状态:0 未使用 1 已使用 2 已过期 3 已作废 */
export const COUPON_STATUS_UNUSED = 0
export const COUPON_STATUS_USED = 1
export const COUPON_STATUS_EXPIRED = 2
export const COUPON_STATUS_VOID = 3
export interface CouponSummary {
usable: number
/** 可抵扣券(仅免单券) */
freebie: number
expiring_soon: number
used: number
expired: number
}
export interface UserCoupon {
id: number
coupon_id: number
title: string
sub_title: string
coupon_type: number
kind: number
value: number
threshold_cents: number
status: number
expire_at: string
granted_at: string
used_at: string
}
export interface CouponMy {
summary: CouponSummary
list: UserCoupon[]
}
/** 当前订单可选用券(**只返免单券**,约定 S7) */
export interface UsableCoupon {
user_coupon_id: number
title: string
coupon_type: number
is_freebie: boolean
expire_at: string
estimated_paid_price_cents: number
/** 是否为「当前订单已选中」的券(仅当请求带 outTradeNo 时可能为 true) */
applied: boolean
}
export interface CouponValidateResult {
valid: boolean
coupon_type: number
is_freebie: boolean
product_id: string
price_cents: number
origin_price_cents: number
paid_price_cents: number
message: string
}
/** 我的优惠券(status 可空:0 可用 / 1 已用 / 2 过期) */
export function apiCouponMy(status?: number) {
return get<CouponMy>('/coupon/my', status === undefined ? undefined : { status })
}
/**
* 当前订单可选用的券(只返免单券)。
*
* outTradeNo 可选:传入本人订单号时,后端会**保底**返回该订单当前已选中的那张券
* (标记 applied=true)——因该券下单占用后 status 变 1,会被「仅返未使用券」的过滤排除,
* 导致用户只有一张券时列表变空、看不到也换不了(P0)。缺省为空串时行为与旧版一致。
*/
export function apiCouponUsable(orderType: number, targetKey: string, outTradeNo = '') {
return get<{ list: UsableCoupon[] }>('/coupon/usable', { orderType, targetKey, outTradeNo })
}
/** 校验免单券可用性(只读,不占用) */
export function apiCouponValidate(userCouponId: number, orderType: number, targetKey: string) {
return post<CouponValidateResult>('/coupon/validate', { userCouponId, orderType, targetKey })
}

171
src/api/event.ts Normal file
View File

@@ -0,0 +1,171 @@
import { get, post } from '@/api/request'
/**
* 年会域公开接口封装(对应后端 `api/user/v1/event.go`,走**公开组** `EventPub`,**免登录**)。
*
* 契约要点(与后端 `g.Meta` 的 path/method **逐字对齐**,改后端务必同步此处):
* - GET `/event/info` 活动公开信息(event_id)→ 查座页顶部 title/venue/event_time/status/can_query/stage
* - POST `/event/seat/query` 扫码查座(event_id + name + phone 双因子)
*
* 🔴 隐私红线(PRD-02 §4.2.3 / R7-13 / R7-22):
* 查座返回体只含**本人**桌号/桌名/座位号,以及「本人所在桌」的示意(各座位是否有人),
* `SeatCell` **刻意只有 seat_no / is_self / filled 三个字段**,不含任何他人姓名/手机号/名单/统计。
* 前端**绝不允许**推断或展示他人身份,也不要试图请求别桌数据(后端根本不提供)。
*/
/**
* 活动不存在或未发布(与后端 `internal/consts.CodeEventUnavailable` 一致,架构 §8.1)。
* 文案:「活动未开放查询」。
*/
export const CODE_EVENT_UNAVAILABLE = 4005
/** 查座频率超限(与后端 `internal/consts.CodeSeatRateLimited` 一致)。与 4007 合并为同一句文案。 */
export const CODE_SEAT_RATE_LIMITED = 4006
/** 查座连续失败冷却中(与后端 `internal/consts.CodeSeatCooling` 一致)。与 4006 合并为同一句文案。 */
export const CODE_SEAT_COOLING = 4007
/** 4006/4007 合并后的用户可见文案(与后端 `seatBusyMessage()` 一致,PRD-02 §4.2.3)。 */
export const SEAT_BUSY_MESSAGE = '操作过于频繁,请稍后再试'
/** 4005 的用户可见文案(PRD-02 R7-15 / 判据⑤)。 */
export const EVENT_UNAVAILABLE_MESSAGE = '活动未开放查询'
/**
* 大厅逻辑坐标系尺寸(架构 M3 决策:固定 **1600×1200** 逻辑像素)。
* `stage` 的 x/y/w/h 都在这个坐标系里,前端按视口等比缩放。
*/
export const HALL_LOGIC_W = 1600
export const HALL_LOGIC_H = 1200
/**
* 舞台形状取值(后端为自由字符串,架构 §8.2 / PRD Q11:矩形 / 半圆 / 梯形 至少三种)。
* 前端对**未知值一律 fallback 画矩形**(`normalizeStageShape`)。
*/
export const STAGE_SHAPE_RECT = 'rect'
export const STAGE_SHAPE_SEMICIRCLE = 'semicircle'
export const STAGE_SHAPE_TRAPEZOID = 'trapezoid'
/**
* 舞台信息(后端 `StageOut`;命中与活动公开信息两处都返回,用于定位)。
* x/y/w/h 均在 {@link HALL_LOGIC_W}×{@link HALL_LOGIC_H} 逻辑坐标系内。
*/
export interface StageInfo {
x: number
y: number
w: number
h: number
/** 自由字符串,前端做 normalize;未知值按 rect 处理 */
shape: string
/** 旋转角(度) */
rotation: number
/** 颜色(CSS 颜色串;空串时前端取主题色兜底) */
color: string
}
/** 活动公开信息(后端 `EventInfoRes`),免登录。 */
export interface EventInfo {
title: string
venue: string
/** 展示用时间串(已格式化) */
event_time: string
/** draft / published / ended */
status: string
/** true 仅当 status==published(唯一可查座状态,PRD 判据⑤) */
can_query: boolean
/** 舞台(可为 null:未设置舞台) */
stage: StageInfo | null
}
/**
* 「本人所在桌」的一个座位示意单元(后端 `SeatCell`)。
* 🔴 只有这三个字段——没有姓名、没有手机号、没有他人身份。
*/
export interface SeatCell {
seat_no: number
/** 是否本人所在座位 */
is_self: boolean
/** 是否有人(**不返回是谁**) */
filled: boolean
}
/**
* 查座结果(后端 `EventSeatQueryRes`)。
*
* `found=false` 时后端的 `message` 已是统一模糊文案(防枚举,PRD-02 R7-21),
* 其余字段为空 —— 前端**直接展示 message,不要再自行拼文案、不要区分「手机号不存在」与「姓名不匹配」**。
*/
export interface SeatQueryResult {
found: boolean
/** found=false 时为统一模糊文案;found=true 时为 'ok'(不展示) */
message: string
table_no: string
table_name: string
seat_no: number
capacity: number
/** 本人所在桌,按座位号升序;只含 seat_no / is_self / filled */
seats: SeatCell[]
/** 舞台(可为 null) */
stage: StageInfo | null
/**
* 本人桌圆心 x(大厅逻辑坐标系,后端 `EventSeatQueryRes` 新增)。
* ⚠️ **仅 `found=true` 时有意义;未命中时后端返回 0**。
* 与 {@link hall_w}×{@link hall_h} 配合,前端按真实相对位置定位并高亮本人桌。
*/
table_x: number
/**
* 本人桌圆心 y(语义同 {@link table_x})。
* ⚠️ 未命中为 0。
*/
table_y: number
/**
* 大厅逻辑宽(后端取自 `hall_layout.canvas.w`)。
* ⚠️ 缺省 / 未命中时为 0,前端**必须回退** {@link HALL_LOGIC_W}。
*/
hall_w: number
/**
* 大厅逻辑高(语义同 {@link hall_w},缺省回退 {@link HALL_LOGIC_H})。
*/
hall_h: number
}
/** 公开可查询活动项(后端 `EventPublicItem`):只含已发布(can_query=true)活动。 */
export interface EventListItem {
event_id: number
title: string
venue: string
/** 展示用时间串(已格式化) */
event_time: string
}
/** 活动公开信息(免登录,查座页顶部)。 */
export function apiEventInfo(eventId: number) {
return get<EventInfo>('/event/info', { event_id: eventId })
}
/** 扫码查座(免登录,姓名 + 手机号双因子精确匹配)。 */
export function apiEventSeatQuery(eventId: number, name: string, phone: string) {
return post<SeatQueryResult>('/event/seat/query', { event_id: eventId, name, phone })
}
/**
* 公开可查询活动列表(免登录)。
* 只返回已发布(can_query=true)的活动;无已发布活动时后端返回 `{ list: [] }`(非报错)。
*/
export function apiEventList() {
return get<{ list: EventListItem[] }>('/event/list')
}
/** 舞台形状归一化:`rect` / `semicircle` / `trapezoid`,未知值一律 fallback 为 `rect`。 */
export function normalizeStageShape(shape: string): string {
switch ((shape || '').toLowerCase()) {
case STAGE_SHAPE_SEMICIRCLE:
return STAGE_SHAPE_SEMICIRCLE
case STAGE_SHAPE_TRAPEZOID:
return STAGE_SHAPE_TRAPEZOID
case STAGE_SHAPE_RECT:
return STAGE_SHAPE_RECT
default:
return STAGE_SHAPE_RECT
}
}

View File

@@ -49,7 +49,22 @@ export interface MemberCenter {
period_days: number
}
/** 下单返回的支付参数,需原样交给 wx.requestVirtualPayment */
/**
* 建单结果(POST /member/order 与 /quota/order 共用形状)。
*
* N1 两步式的**第一步只建单**:不返回任何支付参数。支付参数由 /member/order/pay
* (或 /quota/order/pay)构建(见 {@link MemberPayData})。
* reused=true 表示命中「同用户 + 同商品 + 待支付」的既有单(待支付单恒为 1 条)。
*/
export interface MemberOrderCreate {
out_trade_no: string
reused: boolean
origin_price_cents: number
paid_price_cents: number
status: number
}
/** 下单/建签名返回的支付参数,需原样交给 wx.requestVirtualPayment */
export interface MemberPayData {
out_trade_no: string
mode: string
@@ -59,12 +74,19 @@ export interface MemberPayData {
env: number
}
/** 优惠凭证类型:0 无 / 1 优惠码 / 2 免单券 */
export const PROMO_KIND_NONE = 0
export const PROMO_KIND_CODE = 1
export const PROMO_KIND_COUPON = 2
export interface MemberOrder {
out_trade_no: string
/** 1=会员套餐 2=次数包 */
order_type: number
plan_key: string
plan_name: string
/** 次数包 key(order_type=2 时用于查询可选免单券) */
pack_key: string
/** 统一展示标题(套餐名 / 次数包名) */
title: string
tool_key: string
@@ -76,6 +98,53 @@ export interface MemberOrder {
pay_channel: string
created_at: string
delivered_at: string
/** ===== 优惠凭证(响应 snake_case)===== */
origin_price_cents: number
paid_price_cents: number
promo_code: string
discount_cents: number
promo_kind: number
}
/**
* 订单详情(GET /member/order/detail,响应 snake_case)。
*
* 注意:`promo_kind` 为 2(免单券)时 `user_coupon_id` 才有意义。
*/
export interface MemberOrderDetail {
out_trade_no: string
order_type: number
title: string
plan_key: string
plan_name: string
/** 次数包 key(order_type=2 时用于查询可选免单券) */
pack_key: string
tool_key: string
tool_name: string
times: number
origin_price_cents: number
paid_price_cents: number
discount_cents: number
promo_kind: number
promo_code: string
user_coupon_id: number
/** 0 待支付 1 已发货 2 已退款 3 已关闭 */
status: number
status_text: string
created_at: string
delivered_at: string
}
/** 应用 / 更换 / 清除优惠结果 */
export interface ApplyVoucherResult {
valid: boolean
origin_price_cents: number
paid_price_cents: number
discount_cents: number
promo_kind: number
promo_code: string
user_coupon_id: number
message: string
}
export interface MemberOrderCheck {
@@ -89,12 +158,36 @@ export function apiMemberCenter() {
return get<MemberCenter>('/member/center')
}
/** 建单(第一步):只落一条待支付订单,**不返回支付参数** */
export function apiMemberOrderCreate(planKey: string) {
return post<MemberOrderCreate>('/member/order', { plan_key: planKey })
}
/**
* 创建会员订单。
* code 来自 wx.login,服务端用它换 session_key 生成用户态签名,并校验支付人与登录人一致。
* 建签名(第二步)。code 来自 wx.login,服务端用它换 session_key 生成用户态签名,
* 并在此处校验「支付人 = 登录人」。
*/
export function apiMemberOrder(planKey: string, code: string) {
return post<MemberPayData>('/member/order', { plan_key: planKey, code })
export function apiMemberOrderPay(outTradeNo: string, code: string) {
return post<MemberPayData>('/member/order/pay', { out_trade_no: outTradeNo, code })
}
/** 订单详情 */
export function apiMemberOrderDetail(outTradeNo: string) {
return get<MemberOrderDetail>('/member/order/detail', { out_trade_no: outTradeNo })
}
/**
* 应用 / 更换 / 清除订单优惠(应用时占用,D3)。
* promoCode 与 userCouponId **互斥**;两者皆空 = 清除优惠。
*
* silent=true:请求层不弹统一错误 toast(供「订单默认自动选券」静默调用,P0)。
*/
export function apiMemberOrderApplyVoucher(outTradeNo: string, promoCode = '', userCouponId = 0, silent = false) {
return post<ApplyVoucherResult>('/member/order/apply-voucher', {
out_trade_no: outTradeNo,
promo_code: promoCode,
user_coupon_id: userCouponId,
}, { silent })
}
/** 查询订单状态(前端支付成功回调后轮询;服务端同时做查单兜底发货) */

128
src/api/note.ts Normal file
View File

@@ -0,0 +1,128 @@
import { get, post } from '@/api/request'
/**
* 笔记接口封装(对应后端 `api/user/v1/note.go`,全部走 `UserAuth` 组,需登录态)。
*
* 契约要点(与后端 `g.Meta` 的 path/method **逐字对齐**,改后端务必同步此处):
* - GET `/note/list` 列表(keyword/tag/page/pageSize)+ 额度计数
* - GET `/note/detail` 详情(id)
* - POST `/note/save` 新建(id=0,计额度)/ 编辑(id>0,不计额度)
* - POST `/note/delete` 软删(deleted_at 置位,**立即释放额度**)
* - POST `/note/restore` 回收站恢复(P2;恢复计入额度)
* - POST `/note/remind-cancel` 取消提醒
* - POST `/note/subscribe-report` 订阅授权成功回传(一次授权 = 一次可发送)
*/
/**
* 笔记错误码(与后端 `internal/consts` 一致,架构 §8.1)。
* 与 `request.ts` 的 `CODE_QUOTA_EXHAUSTED` 同源约定:数字必须两端一致。
*/
export const CODE_NOTE_LIMIT = 4003
export const CODE_NOTE_NOT_FOUND = 4004
/** 免费版活跃笔记上限(与后端 `consts.NoteFreeLimit` 一致) */
export const NOTE_FREE_LIMIT = 50
/** `remind_status` 枚举(与后端 `consts` 一致,架构 §8.4) */
export const NOTE_REMIND_NONE = 0
export const NOTE_REMIND_PENDING = 1
export const NOTE_REMIND_SENT = 2
export const NOTE_REMIND_FAILED = 3
export const NOTE_REMIND_EXPIRED = 4
/** 列表项(后端 `NoteOut`) */
export interface NoteItem {
id: number
title: string
/** 正文摘要(列表不返回全文) */
summary: string
tags: string[]
is_pinned: boolean
/** 空串 = 未设置 */
remind_at: string
/** 0..4,见 NOTE_REMIND_* */
remind_status: number
updated_at: string
}
/** 列表返回(后端 `NoteListRes`) */
export interface NoteListData {
list: NoteItem[]
total: number
/** 活跃笔记数(不含回收站) */
active_count: number
/** 免费上限 50;会员为 0(表示不限) */
limit: number
is_member: boolean
}
/** 详情(后端 `NoteDetailRes`) */
export interface NoteDetail {
id: number
title: string
content: string
tags: string[]
is_pinned: boolean
remind_at: string
remind_status: number
}
/** 保存入参(后端 `NoteSaveReq`;id=0 新建、id>0 编辑;remind_at 空串=取消提醒) */
export interface NoteSavePayload {
id: number
title: string
content: string
tags: string[]
is_pinned: boolean
/** 格式 `yyyy-MM-dd HH:mm:ss`;空串 = 不设 / 取消提醒 */
remind_at: string
}
/** 保存 / 恢复返回(后端 `NoteSaveRes`) */
export interface NoteSaveResult {
id: number
active_count: number
}
/** 列表查询参数 */
export interface NoteListQuery {
keyword?: string
tag?: string
page?: number
pageSize?: number
}
/** 笔记列表(分页 / 关键词搜索 / 标签筛选)+ 额度计数 */
export function apiNoteList(query: NoteListQuery = {}) {
return get<NoteListData>('/note/list', { ...query })
}
/** 单条笔记详情 */
export function apiNoteDetail(id: number) {
return get<NoteDetail>('/note/detail', { id })
}
/** 新建 / 编辑笔记 */
export function apiNoteSave(payload: NoteSavePayload) {
return post<NoteSaveResult>('/note/save', payload)
}
/** 删除笔记(软删,释放额度) */
export function apiNoteDelete(id: number) {
return post<Record<string, never>>('/note/delete', { id })
}
/** 从回收站恢复(P2) */
export function apiNoteRestore(id: number) {
return post<NoteSaveResult>('/note/restore', { id })
}
/** 取消提醒 */
export function apiNoteRemindCancel(id: number) {
return post<Record<string, never>>('/note/remind-cancel', { id })
}
/** 上报订阅授权次数(`note_id` 必填、`count` ≥ 1) */
export function apiNoteSubscribeReport(noteId: number, count = 1) {
return post<Record<string, never>>('/note/subscribe-report', { note_id: noteId, count })
}

View File

@@ -56,7 +56,27 @@ export interface QuotaMy {
packs: QuotaPack[]
}
/** 次数包下单返回的支付参数,需原样交给 wx.requestVirtualPayment */
/**
* 次数包**建单**结果(POST /quota/order,N1 两步式第一步)。
* 只落一条待支付订单,**不返回支付参数**;支付参数由 /quota/order/pay 构建。
*/
export interface QuotaOrderCreate {
out_trade_no: string
reused: boolean
origin_price_cents: number
paid_price_cents: number
pack_key: string
tool_key: string
times: number
valid_days: number
status: number
}
/**
* 次数包订单支付参数(POST /quota/order/pay)。
* ⚠️ signData / paySig / signature / env 由微信 wx.requestVirtualPayment 规定,
* 必须原样交给收银台,**禁止改名**(改名会导致收银台直接失败)。
*/
export interface QuotaPayData {
out_trade_no: string
mode: string
@@ -64,10 +84,6 @@ export interface QuotaPayData {
paySig: string
signature: string
env: number
pack_key: string
tool_key: string
times: number
valid_days: number
}
/** 我的额度总览 */
@@ -90,10 +106,15 @@ export function apiQuotaPacks(toolKey?: string) {
return get<{ list: QuotaPack[] }>('/quota/packs', toolKey ? { tool_key: toolKey } : undefined)
}
/**
* 创建次数包订单。
* code 来自 wx.login,服务端用它换 session_key 生成用户态签名,并校验支付人与登录人一致。
*/
export function apiQuotaOrder(packKey: string, toolKey: string, code: string) {
return post<QuotaPayData>('/quota/order', { pack_key: packKey, tool_key: toolKey, code })
/** 建单(第一步):只落一条待支付次数包订单,**不返回支付参数** */
export function apiQuotaOrderCreate(packKey: string, toolKey: string) {
return post<QuotaOrderCreate>('/quota/order', { pack_key: packKey, tool_key: toolKey })
}
/**
* 建签名(第二步)。code 来自 wx.login,服务端用它换 session_key 生成用户态签名,
* 并在此处校验「支付人 = 登录人」(该校验已从建单前移到这里)。
*/
export function apiQuotaOrderPay(outTradeNo: string, code: string) {
return post<QuotaPayData>('/quota/order/pay', { out_trade_no: outTradeNo, code })
}

View File

@@ -15,6 +15,35 @@ const TOKEN_KEY = 'token'
*/
export const CODE_QUOTA_EXHAUSTED = 4002
/**
* 笔记数量超上限,与后端 internal/consts.CodeNoteLimit 一一对应(架构 §8.1)。
* 同样定义在此以避免 request → note → request 的循环依赖;
* 4003 不在此弹 toast,交由笔记页弹出「会员引导」层,避免同一个提示叠两层。
*/
export const CODE_NOTE_LIMIT = 4003
/**
* 年会查座错误码(与后端 internal/consts 一一对应,架构 §8.1)。
* 同样定义在此以避免 request → event → request 的循环依赖;
* 4005/4006/4007 不在此弹 toast,交由查座页统一展示 —— 因为 4006/4007
* 必须**合并为同一句文案**(「操作过于频繁,请稍后再试」),后端两者的原始
* message 未必一致,只有页面层能安全地做合并,避免双 toast。
*/
export const CODE_EVENT_UNAVAILABLE = 4005
export const CODE_SEAT_RATE_LIMITED = 4006
export const CODE_SEAT_COOLING = 4007
/**
* 订单 / 优惠域错误码(与后端 internal/consts 一一对应)。
* - 4015 订单不存在 / 不属于当前用户 / 状态不允许该操作(N1 两步式)
* - 4011 优惠券不可用(已被占用 / 过期 / 不适用于当前商品)
*
* 这两类走后端的**统一 toast**(后端 message 已是可读中文),错误对象仍携带 `code`
* 供页面按需分支(如订单详情页据此提示「已关闭,请重新下单」)。
*/
export const CODE_ORDER_INVALID = 4015
export const CODE_COUPON_UNAVAILABLE = 4011
export function getToken(): string {
return (uni.getStorageSync(TOKEN_KEY) as string) || ''
}
@@ -27,6 +56,16 @@ export function clearToken() {
uni.removeStorageSync(TOKEN_KEY)
}
/**
* 401(登录失效)时的额外回调(由 stores/user.ts 注册,用于清空内存登录态)。
* request 层不能直接 import store(会造成 request → store → api → request 循环依赖),
* 故用「注册回调」解耦。H5 侧另有 gotoH5Login 引导回登录页。
*/
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
unauthorizedHandler = fn
}
/** H5:401 时引导去登录页(登录页自身不跳,避免循环) */
export function gotoH5Login() {
// #ifdef H5
@@ -40,7 +79,7 @@ export function gotoH5Login() {
// #endif
}
function request<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown): Promise<T> {
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)
@@ -61,6 +100,7 @@ function request<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown)
}
if (body && body.code === 401) {
clearToken()
unauthorizedHandler?.()
gotoH5Login()
reject(new Error(body.message || 'unauthorized'))
return
@@ -73,28 +113,51 @@ function request<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown)
reject(quotaErr)
return
}
// 4001 等业务错误码走统一提示
uni.showToast({ title: (body && body.message) || '请求失败', icon: 'none' })
// 4003 笔记超上限:与 4002 同策略,不在此弹 toast,交由笔记页弹出会员引导层
if (body && body.code === CODE_NOTE_LIMIT) {
const noteErr = new Error(body.message || '免费版最多 50 条,开通会员可不限量')
;(noteErr as Error & { code?: number }).code = body.code
reject(noteErr)
return
}
// 4005/4006/4007 年会查座错误:与 4003 同策略,不在此弹 toast,交由查座页统一展示
// (4006/4007 在页面侧合并为同一句文案,故此层不得先行 toast)
if (
body
&& (body.code === CODE_EVENT_UNAVAILABLE
|| body.code === CODE_SEAT_RATE_LIMITED
|| body.code === CODE_SEAT_COOLING)
) {
const eventErr = new Error(body.message || 'request failed')
;(eventErr as Error & { code?: number }).code = body.code
reject(eventErr)
return
}
// 4001 等业务错误码走统一提示(silent=true 时静默:供「订单默认自动选券」等场景,
// 失败不打扰用户,UI 已有「已选免单券」标签兜底展示)
if (!opts?.silent)
uni.showToast({ title: (body && body.message) || '请求失败', icon: 'none' })
const err = new Error((body && body.message) || 'request failed')
;(err as Error & { code?: number }).code = body && body.code
reject(err)
},
fail: (err) => {
uni.showToast({ title: '网络异常,请检查服务是否启动', icon: 'none' })
if (!opts?.silent)
uni.showToast({ title: '网络异常,请检查服务是否启动', icon: 'none' })
reject(new Error(err.errMsg || 'network error'))
},
})
})
}
export function get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
return request<T>(url, 'GET', params)
export function get<T>(url: string, params?: Record<string, unknown>, opts?: { silent?: boolean }): Promise<T> {
return request<T>(url, 'GET', params, opts)
}
export function post<T>(url: string, data?: unknown): Promise<T> {
return request<T>(url, 'POST', data)
export function post<T>(url: string, data?: unknown, opts?: { silent?: boolean }): Promise<T> {
return request<T>(url, 'POST', data, opts)
}
export function put<T>(url: string, data?: unknown): Promise<T> {
return request<T>(url, 'PUT', data)
export function put<T>(url: string, data?: unknown, opts?: { silent?: boolean }): Promise<T> {
return request<T>(url, 'PUT', data, opts)
}

47
src/api/upload.ts Normal file
View File

@@ -0,0 +1,47 @@
import { BASE_URL } from '@/config/env'
import { getToken } from '@/api/request'
/**
* 头像上传(N3)。对接后端 `POST /user/avatar`(multipart/form-data,字段名固定为 `file`)。
*
* 契约要点:
* - 成功返回 `avatar_url`(**我方持久 URL**,换设备仍可显示,不是微信临时路径);
* - 体积 >5MB / 非图片 → 后端返回 **4016**;写盘失败 → **4017**(均带可读中文 message)。
* 失败时**不得改动原头像**(由调用方保证:只在成功后才写回)。
*/
export interface AvatarUploadResult {
avatar_url: string
}
/** 上传头像,返回后端持久 URL。失败时 reject 的 Error 携带后端 code(4016/4017)。 */
export function apiUploadAvatar(filePath: string): Promise<AvatarUploadResult> {
return new Promise((resolve, reject) => {
const token = getToken()
uni.uploadFile({
url: `${BASE_URL}/user/avatar`,
filePath,
name: 'file',
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
let body: { code?: number, message?: string, result?: AvatarUploadResult } | null = null
try {
body = JSON.parse((res.data as string) || 'null')
}
catch {
body = null
}
if (body && body.code === 0 && body.result) {
resolve(body.result)
return
}
const err = new Error((body && body.message) || '头像上传失败,请重试') as Error & { code?: number }
err.code = body?.code
reject(err)
},
fail: (err) => {
reject(new Error(err.errMsg || '头像上传失败,请重试'))
},
})
})
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Z } from '@/utils/z-index'
/**
* 彩带庆祝:`burst` 数值每次自增触发一次彩带撒落,1.6s 后自动收场
@@ -35,7 +36,7 @@ const pieces = computed(() =>
</script>
<template>
<view v-if="visible" class="confetti">
<view v-if="visible" class="confetti" :style="{ zIndex: Z.decor }">
<view
v-for="p in pieces"
:key="p.id"
@@ -55,7 +56,6 @@ const pieces = computed(() =>
height: 60vh;
overflow: hidden;
pointer-events: none;
z-index: 20;
}
.confetti__piece {
position: absolute;

View File

@@ -24,6 +24,12 @@ const SVGS: Record<string, string> = {
rainbow: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M9 45a23 23 0 0 1 46 0' fill='none' stroke='#FF6B35' stroke-width='7' stroke-linecap='round'/><path d='M17 45a15 15 0 0 1 30 0' fill='none' stroke='#FFC53D' stroke-width='7' stroke-linecap='round'/><path d='M25 45a7 7 0 0 1 14 0' fill='none' stroke='#3ECFB2' stroke-width='7' stroke-linecap='round'/></svg>`,
bolt: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M36 4L14 36h12l-4 24 24-34H32l4-22z' fill='#FFC53D' ${STROKED}/></svg>`,
sparkle: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M32 5c2.4 14.6 8.6 22.6 24 27-15.4 4.4-21.6 12.4-24 27-2.4-14.6-8.6-22.6-24-27 15.4-4.4 21.6-12.4 24-27z' fill='#FF7BA9' ${STROKED}/></svg>`,
// ===== P3:带 linearGradient 的立体插画(复用既有 isometric 描边风,无二进制资源) =====
note: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#FFD98A'/><stop offset='1' stop-color='#FF9F4D'/></linearGradient></defs><rect x='13' y='9' width='38' height='48' rx='9' fill='url(#g)' ${STROKED}/><path d='M22 24h20M22 33h20M22 42h13' stroke='#3D2A1E' stroke-width='3' stroke-linecap='round'/><circle cx='46' cy='46' r='7' fill='#FFFFFF' ${STROKED}/></svg>`,
pin: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#9AD9FF'/><stop offset='1' stop-color='#4DA8FF'/></linearGradient></defs><path d='M32 6c-9.4 0-17 7.6-17 17 0 12.4 17 33 17 33s17-20.6 17-33c0-9.4-7.6-17-17-17z' fill='url(#g)' ${STROKED}/><circle cx='32' cy='23' r='6.6' fill='#FFFFFF' ${STROKED}/></svg>`,
folder: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#B9C9FF'/><stop offset='1' stop-color='#7C93FF'/></linearGradient></defs><path d='M8 20a6 6 0 0 1 6-6h12l5 6h19a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6H14a6 6 0 0 1-6-6z' fill='url(#g)' ${STROKED}/><path d='M8 26h48' stroke='#3D2A1E' stroke-width='3' stroke-linecap='round'/></svg>`,
gift: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#FFB3D1'/><stop offset='1' stop-color='#FF7BA9'/></linearGradient></defs><rect x='11' y='24' width='42' height='31' rx='7' fill='url(#g)' ${STROKED}/><rect x='8' y='17' width='48' height='12' rx='5' fill='#FFD98A' ${STROKED}/><path d='M32 17v38' stroke='#3D2A1E' stroke-width='3'/><path d='M32 17c-4-9-15-7-13 0M32 17c4-9 15-7 13 0' fill='none' stroke='#3D2A1E' stroke-width='3' stroke-linecap='round'/></svg>`,
crown: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#FFE9B8'/><stop offset='1' stop-color='#FFC53D'/></linearGradient></defs><path d='M8 22l10 9 14-16 14 16 10-9-5 30H13z' fill='url(#g)' ${STROKED}/><circle cx='32' cy='14' r='4' fill='#FF7BA9' ${STROKED}/></svg>`,
}
const bgImage = computed(

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import TabIcon from '@/components/TabIcon.vue'
import { Z } from '@/utils/z-index'
export interface TabBarTab {
key: string
@@ -26,7 +27,7 @@ function onTap(key: string) {
</script>
<template>
<view class="tb-tabbar">
<view class="tb-tabbar" :style="{ zIndex: Z.tabbar }">
<view class="tb-tabbar__dock">
<!-- 左:第 1 个 tab -->
<view
@@ -91,8 +92,7 @@ function onTap(key: string) {
right: 28rpx;
/* 底部安全距离:Home Indicator 之上悬浮 */
bottom: calc(22rpx + env(safe-area-inset-bottom, 0px));
/* 低于 wot 弹层(z-index 10),避免弹层被 TabBar 压住 */
z-index: 9;
/* 层级来自唯一来源 z-index.ts(Z.tabbar=1000);低于抽屉/弹层(sheet=2000) */
}
.tb-tabbar__dock {
position: relative;

View File

@@ -0,0 +1,757 @@
<script setup lang="ts">
/**
* 首页**唯一**卡片渲染组件(P3 瓷片区重做)。
*
* 设计:**版式(template)与皮肤(variant)正交**
* - 版式:iconTop 图标墙 / iconLeft 行卡 / lead 通栏主卡 / duo 双列渐变卡 / group 分组容器卡;
* - 皮肤:porcelain / tint / glass / solid / line(沿用 P2,已验过);
* - 色系:gold / blue / mint / rose / violet(tone,走 CSS 变量注入)。
* 根元素同时带 `.ct--<template>` 与 `.ct--skin-<variant>` 两个 class,二者互不耦合。
*
* `group` 版式用默认插槽承载子卡网格;其余版式为单卡自渲染。
* 本组件不涉及浮层,**禁止写裸 z-index**(S15)。
*/
import { computed } from 'vue'
import Sticker from '@/components/Sticker.vue'
import { CARD_TONES } from '@/config/card-styles'
const props = withDefaults(defineProps<{
/** 版式(见 CARD_TEMPLATES) */
template?: string
/** 皮肤(见 CARD_VARIANTS) */
variant?: string
/** 色系(见 CARD_TONES) */
tone?: string
/** wot 图标名 */
icon?: string
/** 标题 */
title?: string
/** 描述 */
desc?: string
/** 右上角角标文案(locked 时被「未解锁」覆盖) */
badge?: string
/** 尺寸 */
size?: 'sm' | 'md' | 'lg'
/** 是否未解锁(灰化 + 角标「未解锁」) */
locked?: boolean
/** 插画 / 贴纸 key(见 Sticker.vue 的 SVGS;用于 lead/duo/group/iconTop 版式) */
illustration?: string
/** 行卡(iconLeft)右端是否显示箭头(用于「我的」菜单行) */
arrow?: boolean
/** 分组容器卡(group)子卡网格列数(默认 2;工具楼层用 4,纵向列表用 1) */
columns?: number
/**
* group 版式是否由本组件自带子卡网格(默认 true)。
* 置 false 时渲染裸 slot,由**调用方用自己的 scoped 网格**承接 —— P5 实证:
* 同样的子卡在工作台自管网格(.wb-grid)里横排正常,在组件内联 style 的 .ct__grid 里
* 却逐字竖排(MP 环境差异),故提供逃生口让调用方复用「已被真机验证」的网格写法。
*/
grid?: boolean
// ===== v2 版式原子(全部**可选**,缺省 = 现状,零回归)=====
/** 角标位置:`tr` 右上贴边(默认,除 tile 外)/ `tl` 左上小 pill(tile 默认,参考图 B) */
badgePos?: 'tr' | 'tl'
/** 图标块形状:`square` 圆角方(默认)/ `circle` 圆形(参考图 C1) */
iconShape?: 'square' | 'circle'
/** 插画位置:`top` 顶部(默认,除 duo/tile 外)/ `rightBottom` 右下大插画(duo/tile 默认,参考图 C2/B) */
artPos?: 'top' | 'rightBottom'
/** 胶囊 CTA 文案(非空时在标题下方渲染 `.ct__cta`;默认空串不渲染,参考图 C4) */
cta?: string
/** group 版式子卡网格是否用 mosaic(2 列 + 通栏行;默认 false = 按 `columns`)。参考图 A */
mosaic?: boolean
/** 本卡作为容器内「通栏行」渲染(根元素加 `.ct__row`,跨整行;默认 false) */
row?: boolean
/** group 版式右上胶囊文案(非空时渲染 `.ct__pill`;默认空串不渲染) */
pill?: string
}>(), {
template: 'iconTop',
variant: 'porcelain',
tone: 'gold',
icon: '',
title: '',
desc: '',
badge: '',
size: 'md',
locked: false,
illustration: '',
arrow: false,
columns: 2,
grid: true,
})
/** 色系元数据(未知 tone 回落首个,绝不抛错) */
const toneMeta = computed(() => CARD_TONES.find(t => t.id === props.tone) || CARD_TONES[0])
/** 色系 → CSS 变量(供各 skin / 版式 class 消费) */
const toneVars = computed<Record<string, string>>(() => ({
'--ct-from': toneMeta.value.from,
'--ct-to': toneMeta.value.to,
'--ct-accent': toneMeta.value.accent,
'--ct-tint': toneMeta.value.tintBg,
'--ct-icon-bg': toneMeta.value.iconBg,
'--ct-line': toneMeta.value.lineBorder,
// 强渐变(solid 皮肤 / duo 版式专用):饱和端点,避免用 accent(文字深色)起笔而发暗
'--ct-grad-from': toneMeta.value.gradFrom,
'--ct-grad-to': toneMeta.value.gradTo,
// 角标:随色系派生(高对比),勿用固定金色(会与 gold 卡同色不可见)
'--ct-badge-bg': toneMeta.value.badgeBg,
'--ct-badge-fg': toneMeta.value.badgeFg,
// 彩色环境投影(参考图质感关键:随本卡色相的低透明色,替代灰黑阴影)
'--ct-shadow': toneMeta.value.shadow,
// 层叠卡(stack)两层厚度色
'--ct-layer': toneMeta.value.layer,
'--ct-layer-soft': toneMeta.value.layerSoft,
}))
/** 深色皮肤/强渐变版式用白图标 */
const darkSkin = computed(() => props.variant === 'solid' || props.template === 'duo')
const iconColor = computed(() => {
if (props.locked)
return '#BDA794'
return darkSkin.value ? '#FFFFFF' : toneMeta.value.accent
})
/** 插画尺寸随 size 放大(lg ≥ 128rpx) */
const artSize = computed(() => (props.size === 'lg' ? 128 : props.size === 'sm' ? 72 : 96))
/** 图标墙(iconTop)插画尺寸(rpx;与放大的图标块同档,参考图插画占比更大) */
const iconArtPx = computed(() => (props.size === 'lg' ? 120 : props.size === 'sm' ? 68 : 96))
/** 角标文案:未解锁优先 */
const badgeText = computed(() => (props.locked ? '未解锁' : props.badge))
/**
* v2 版式原子的「生效值」:显式 prop 优先,否则按版式给默认(缺省即现状)。
* - 角标:tile 默认左上小 pill(参考图 B),其余右上贴边(现状);
* - 插画:duo(参考图 C2)与 tile(参考图 B)默认右下大插画,其余顶部(现状);
* - 图标形状:默认圆角方(现状)。
*/
const effBadgePos = computed<'tr' | 'tl'>(() => props.badgePos || (props.template === 'tile' ? 'tl' : 'tr'))
const effIconShape = computed<'square' | 'circle'>(() => props.iconShape || 'square')
const effArtPos = computed<'top' | 'rightBottom'>(() =>
props.artPos || ((props.template === 'duo' || props.template === 'tile') ? 'rightBottom' : 'top'),
)
</script>
<template>
<view
class="ct"
:class="[`ct--${template}`, `ct--skin-${variant}`, `ct--${size}`, { 'ct--lock': locked, 'ct__row': row }]"
:style="toneVars"
>
<!-- ===== 分组容器卡:白底 + 标题 + 右上角标 + 子卡插槽 ===== -->
<template v-if="template === 'group'">
<view v-if="title || badgeText || pill" class="ct__head">
<view class="ct__headlead">
<Sticker v-if="illustration" :type="illustration" :size="52" float />
<text class="ct__headtitle">
{{ title }}
</text>
</view>
<!-- 通栏标题行右胶囊(参考图 D;非空才渲染,默认不渲染 → 零回归) -->
<text v-if="pill" class="ct__pill">
{{ pill }}
</text>
<text v-if="badgeText" class="ct__headmore">
{{ badgeText }}
</text>
</view>
<!-- grid=false 时渲染裸 slot:由调用方用自己的 scoped 网格承接(P5 实证 MP 对内联 style 的 repeat() 网格支持不可靠,scoped class 已被工作台真机验证可用)。
网格铁律:`grid-template-columns` 只允许出现在 scoped class(`.ct__grid--c<N>` / `.ct__grid--mosaic`),**禁止内联 `repeat()`**(MP 不生效,P5 真事故)。 -->
<view v-if="grid" class="ct__grid" :class="mosaic ? 'ct__grid--mosaic' : `ct__grid--c${columns}`">
<slot />
</view>
<slot v-else />
</template>
<!-- ===== 通栏主卡:左文案 + 右大插画 ===== -->
<template v-else-if="template === 'lead'">
<view class="ct__lead-main">
<text class="ct__title">
{{ title }}
</text>
<text v-if="desc" class="ct__desc">
{{ desc }}
</text>
<view class="ct__extra">
<slot />
</view>
</view>
<view v-if="illustration || icon" class="ct__art ct__art--lead">
<Sticker v-if="illustration" :type="illustration" :size="artSize" float />
<view v-else class="ct__icon ct__icon--art">
<wd-icon :name="icon" :size="`${artSize * 0.5}rpx`" :color="iconColor" />
</view>
</view>
<text v-if="badgeText" class="ct__badge">
{{ badgeText }}
</text>
</template>
<!-- ===== 双列渐变卡:强渐变底 + 右下插画(参考图 C2)+ 标题 + 描述 ===== -->
<template v-else-if="template === 'duo'">
<view class="ct__art" :class="effArtPos === 'rightBottom' ? 'ct__art--br' : 'ct__art--duo'">
<Sticker v-if="illustration" :type="illustration" :size="artSize" />
<view v-else-if="icon" class="ct__icon ct__icon--art" :class="{ 'ct__icon--circle': effIconShape === 'circle' }">
<wd-icon :name="icon" :size="`${artSize * 0.5}rpx`" :color="iconColor" />
</view>
</view>
<text class="ct__title">
{{ title }}
</text>
<text v-if="desc" class="ct__desc">
{{ desc }}
</text>
<view v-if="cta" class="ct__cta">
{{ cta }}
</view>
<text v-if="badgeText" class="ct__badge" :class="{ 'ct__badge--tl': effBadgePos === 'tl' }">
{{ badgeText }}
</text>
</template>
<!-- ===== 行卡:图标左置 + 文案在右 ===== -->
<template v-else-if="template === 'iconLeft'">
<view class="ct__icon">
<wd-icon v-if="icon" :name="icon" size="44rpx" :color="iconColor" />
</view>
<view class="ct__body">
<text class="ct__title">
{{ title }}
</text>
<text v-if="desc" class="ct__desc">
{{ desc }}
</text>
</view>
<text v-if="badgeText" class="ct__badge ct__badge--inline">
{{ badgeText }}
</text>
<wd-icon v-if="arrow" name="arrow-right" size="30rpx" color="#D9C3B2" class="ct__go" />
</template>
<!-- ===== 图标墙(默认,含 tile 瓷片):插画/图标在上 + 标题 + 描述 ===== -->
<template v-else>
<view v-if="illustration" class="ct__art" :class="effArtPos === 'rightBottom' ? 'ct__art--br' : 'ct__art--top'">
<Sticker :type="illustration" :size="iconArtPx" float />
</view>
<view v-else class="ct__icon" :class="{ 'ct__icon--circle': effIconShape === 'circle' }">
<wd-icon v-if="icon" :name="icon" size="46rpx" :color="iconColor" />
</view>
<text class="ct__title">
{{ title }}
</text>
<text v-if="desc" class="ct__desc">
{{ desc }}
</text>
<view v-if="cta" class="ct__cta">
{{ cta }}
</view>
<text v-if="badgeText" class="ct__badge" :class="{ 'ct__badge--tl': effBadgePos === 'tl' }">
{{ badgeText }}
</text>
</template>
</view>
</template>
<!-- ⚠️ :host 必须放在【非 scoped】style 块:uni-app 的 scoped 编译会把它改写成
`.data-v-<id>:host`(scope 后缀加在 :host 之前),而宿主 <card-tile> 上的 class
是【引用方】的 scope id(如 data-v-dde6cf0f),永远不带自己的 data-v-<id>
⇒ 该规则匹配不到任何节点、静默失效(2026-09-24 用户真机实测确证:
`.cttap` 满格 159px 但卡片没撑满它)。非 scoped 的规则不加后缀,:host 才能命中宿主。 -->
<style>
/* ============================== 缺陷 A:MP 宿主撑满(唯一落点) ==============================
MP 下自定义组件宿主 <card-tile> 是 `.cttap`(flex 容器)的 flex 项,不显式撑满会
退化为 max-content → 卡片塌成内容宽(≈4 个中文字)。用 `:host` 匹配宿主
(微信自定义组件 wxss 原生支持,基础库 1.7.2+),**一处规则覆盖全部调用点**,不依赖标签名。
⚠️ 三条禁令:
① 不能在页面/全局侧写 `.cttap > *` 兜底 —— WXSS 不支持通配符 `*`,
微信开发者工具报 `error at token '*'`,整个 app.wxss 编译失败;
② 本规则不能放进 <style scoped>(会被改写成 .data-v-<id>:host 而静默失效,见上);
③ 产物验收必须断言「纯 :host{」(前面不得是 .data-v- 前缀),见 r7-assert-mp-v2.py A4 v4。
H5 端 :host 不匹配任何元素(非 shadow DOM),撑满由 App.vue 的 `.cttap > .ct` 承担。 */
:host {
display: block;
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
</style>
<style scoped>
/* ============================== 骨架 ============================== */
.ct {
position: relative;
box-sizing: border-box;
overflow: hidden;
/* 缺陷 A:撑满宿主槽位。MP 下自定义组件宿主包裹本节点,宿主侧由上方 `:host` 规则撑满,
宿主内部的 `.ct` 仍需自身显式 100%。
height:100% 用于「同排等高」:宿主为 flex-stretch 项时高度确定 → 生效;
父高为 auto 时百分比高度按 CSS 规范退化为 auto → 安全(不会塌高)。 */
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10rpx;
/* 尺寸基准走 CSS 变量(默认 = 基准值):尺寸档 / 容器档只改变量,不改直接值,
避免 `.ct.ct--group`(0,2,0) 吞掉 `.ct--sm`/`.ct--lg`(0,1,0) 的 padding/圆角(R-2 特异度连锁)。 */
--ct-radius: 32rpx;
--ct-pad: 26rpx 26rpx 24rpx;
border-radius: var(--ct-radius);
padding: var(--ct-pad);
/* 双色渐变底 + 内高光 + 外层柔和投影(默认皮肤 porcelain 在此覆写底色/描边) */
}
.ct__title {
font-size: 28rpx;
font-weight: 800;
line-height: 1.25;
color: #3d2a1e;
}
.ct__desc {
font-size: 22rpx;
line-height: 1.45;
color: #6b4f3a;
max-width: 100%;
}
.ct--iconTop .ct__desc,
.ct--duo .ct__desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
/* ===== 图标块(视觉精致化):渐变底 + 内高光 + 多层柔和投影 ===== */
.ct__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
margin-bottom: 6rpx;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.98), var(--ct-icon-bg));
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.95),
inset 0 -6rpx 12rpx rgba(255, 255, 255, 0.6),
0 8rpx 18rpx var(--ct-shadow),
0 2rpx 4rpx rgba(107, 47, 15, 0.08);
}
.ct__icon--art {
width: auto;
height: auto;
padding: 16rpx;
border-radius: 28rpx;
margin-bottom: 0;
}
/* 宫格①·圆形图标(参考图 C1):圆形描边质感 + 内高光 + 彩色投影 */
.ct__icon--circle {
border-radius: 50%;
box-shadow:
inset 0 2rpx 6rpx #fff,
0 8rpx 18rpx var(--ct-shadow);
}
/* ===== 插画位 ===== */
.ct__art {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* 图标墙(iconTop)插画:大号彩色插画(参考图那组卡的视觉主体) */
.ct__art--top {
margin-bottom: 8rpx;
}
/* 宫格②/③ 右下大插画(参考图 C2/C3):贴右下角、轻微旋转,饱和渐变卡专用 */
.ct__art--br {
position: absolute;
right: 16rpx;
bottom: 10rpx;
opacity: 0.96;
transform: rotate(-6deg);
}
/* ===== 角标(随色系派生:白底 + 同色系深字 + 轻投,任意卡底色上都高对比)===== */
.ct__badge {
position: absolute;
top: 0;
right: 0;
padding: 6rpx 18rpx;
font-size: 18rpx;
font-weight: 700;
border-radius: 0 32rpx 0 22rpx;
background: var(--ct-badge-bg);
color: var(--ct-badge-fg);
box-shadow: 0 4rpx 10rpx rgba(107, 47, 15, 0.22);
}
.ct__badge--inline {
position: static;
align-self: center;
border-radius: 999rpx;
flex-shrink: 0;
}
/* 子卡(tile)左上小 pill 角标(参考图 B):与右上贴边的 `.ct__badge` 区分开 */
.ct__badge--tl {
top: 12rpx;
left: 12rpx;
right: auto;
border-radius: 999rpx;
padding: 4rpx 14rpx;
font-size: 18rpx;
box-shadow: 0 4rpx 10rpx var(--ct-shadow);
}
/* 胶囊 CTA 按钮(参考图 C4):白底 + 同色系文字,浮在卡片上 */
.ct__cta {
display: inline-flex;
align-items: center;
gap: 6rpx;
margin-top: 10rpx;
padding: 10rpx 26rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 800;
background: rgba(255, 255, 255, 0.92);
color: var(--ct-accent);
box-shadow: 0 6rpx 14rpx var(--ct-shadow);
}
/* 行卡右端箭头 */
.ct__go {
flex-shrink: 0;
margin-left: 6rpx;
}
/* ============================== 版式 1 · iconTop 图标墙 ============================== */
.ct--iconTop {
align-items: flex-start;
}
/* ============================== 版式 2 · iconLeft 行卡 ============================== */
.ct--iconLeft {
flex-direction: row;
align-items: center;
gap: 20rpx;
}
.ct--iconLeft .ct__icon {
margin-bottom: 0;
}
/* ============================== 版式 3 · lead 通栏主卡 ============================== */
.ct--lead {
flex-direction: row;
align-items: center;
gap: 16rpx;
padding: 30rpx 26rpx;
}
.ct__lead-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.ct--lead .ct__title {
font-size: 34rpx;
/* 缺陷 D:主卡文案不可被右端插画位挤成逐字换行 → 单行 + 省略 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.ct__extra:empty {
display: none;
}
.ct__art--lead {
/* 缺陷 D:原为刚性 width:168rpx,窄屏/长名会把左侧文案挤到逐字换行。
改为可收缩(flex-basis auto + shrink 1)并保留 168rpx 上限,仅在宽裕时占满。 */
flex: 0 1 auto;
min-width: 0;
max-width: 168rpx;
justify-content: flex-end;
}
/* ============================== 版式 4 · duo 双列渐变卡 ==============================
强渐变底(色系饱和端点)+ 白字;皮肤仍叠加描边/阴影细微差异。
特异性 0,2,0(.ct.ct--duo)> 皮肤 0,1,0 → 版式底色不被主题皮肤覆盖。 */
.ct.ct--duo {
background:
radial-gradient(120% 92% at 8% -14%, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 60%),
linear-gradient(150deg, var(--ct-grad-from), var(--ct-grad-to));
align-items: flex-start;
padding: 28rpx 24rpx 26rpx;
}
.ct--duo .ct__title {
color: #ffffff;
/* 插画移到右下(参考图 C2)→ 文本右侧留白避让,防压字。
⚠️ 落到**真实存在的**文本节点(duo 分支刻意没有 .ct__body 包裹),别写永不匹配的选择器。 */
padding-right: 88rpx;
}
.ct--duo .ct__desc {
color: rgba(255, 255, 255, 0.88);
padding-right: 88rpx;
}
.ct__art--duo {
margin-bottom: 8rpx;
}
/* ============================== 版式 5 · group 分组容器卡 ==============================
白底容器 + 区块标题 + 右上「更多」角标 + 子卡网格(子卡由默认插槽渲染)。
特异性 0,2,0(.ct.ct--group)> 皮肤 0,1,0 → 恒为白底,不被主题皮肤染色。 */
.ct.ct--group {
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.55);
/* 容器尺寸基准也走变量:与尺寸档同特异度时靠**源序**(尺寸档在后)让窄列尺寸档胜出(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);
gap: 20rpx;
align-items: stretch;
}
/* tinted 变体(可选):容器非纯白时用浅色 tint 底(参考图 A「容器通栏」的柔和变体) */
.ct.ct--group--tinted {
background:
radial-gradient(120% 92% at 8% -14%, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0) 62%),
var(--ct-tint);
border-color: var(--ct-line);
}
.ct__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
/* 悬浮胶囊标题(参考图 A):半透明白胶囊 + 轻模糊,浮在容器底色之上 */
.ct__headlead {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
background: rgba(255, 255, 255, 0.86);
border-radius: 999rpx;
padding: 4rpx 16rpx;
backdrop-filter: blur(6rpx);
}
.ct__headtitle {
font-size: 32rpx;
font-weight: 800;
color: #3d2a1e;
/* 缺陷 B 防御性加固:group 版式标题长期没有断行策略 → 窄列里会逐字竖排。
统一 nowrap + 省略号,即使将来再有人把窄列配成 group 也不会竖排。 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.ct__headmore {
font-size: 24rpx;
font-weight: 700;
color: #ff6b35;
padding: 4rpx 18rpx;
border-radius: 999rpx;
background: #fff4ec;
border: 1rpx solid #ffd9c4;
}
/* 通栏标题行右胶囊(参考图 D,`.ct__headmore` 的泛化版):橙底白字,醒目 */
.ct__pill {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 8rpx 22rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff9a4d, #fb4e14);
color: #ffffff;
font-weight: 700;
}
.ct__grid {
display: grid;
/* 铁律:`grid-template-columns` 只允许出现在 scoped class(禁止内联 repeat(),MP 不生效)。 */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
/* 列数档:由调用方传入的 columns 决定(模板用 `:class="ct__grid--c<N>"`),彻底避免内联 repeat() */
.ct__grid--c1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.ct__grid--c2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ct__grid--c3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ct__grid--c4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* mosaic(参考图 A 容器内网格):2 列子卡 + 通栏行 `.ct__row`(跨整行) */
.ct__grid--mosaic {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.ct__grid--mosaic > .ct__row {
grid-column: 1 / -1;
}
/* 通栏行子卡(参考图 A:mosaic 内跨整行的横条) */
.ct__row {
display: flex;
align-items: center;
gap: 16rpx;
border-radius: 24rpx;
padding: 18rpx 20rpx;
background: var(--ct-tint);
}
/* ============================== 皮肤(skin)============================== */
/* porcelain:釉面——左上白色高光叠在双色渐变上 + 彩色环境投影 + 顶部内高光 */
.ct--skin-porcelain {
background:
radial-gradient(120% 92% at 8% -14%, rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0) 62%),
linear-gradient(140deg, var(--ct-from), var(--ct-to));
box-shadow:
inset 0 2rpx 8rpx rgba(255, 255, 255, 0.75),
inset 0 -10rpx 20rpx rgba(255, 255, 255, 0.32),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.38),
0 4rpx 10rpx rgba(107, 47, 15, 0.06),
0 14rpx 30rpx var(--ct-shadow);
}
/* tint:淡彩平铺 + 同款釉面高光 + 彩色投影 */
.ct--skin-tint {
background:
radial-gradient(120% 92% at 8% -14%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 62%),
var(--ct-tint);
box-shadow: 0 10rpx 24rpx var(--ct-shadow);
}
/* glass:半透明 + 1rpx 白描边 + 内高光 */
.ct--skin-glass {
background: rgba(255, 255, 255, 0.55);
border: 1rpx solid rgba(255, 255, 255, 0.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);
}
/* solid:饱和渐变 + 釉面高光 + 彩色投影(不是灰黑) */
.ct--skin-solid {
background:
radial-gradient(120% 92% at 8% -14%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0) 60%),
linear-gradient(140deg, var(--ct-grad-from), var(--ct-grad-to));
box-shadow: 0 6rpx 14rpx rgba(107, 47, 15, 0.1), 0 16rpx 32rpx var(--ct-shadow);
}
.ct--skin-solid .ct__title {
color: #ffffff;
}
.ct--skin-solid .ct__desc {
color: rgba(255, 255, 255, 0.85);
}
.ct--skin-solid .ct__icon {
background: rgba(255, 255, 255, 0.26);
box-shadow: inset 0 2rpx 6rpx rgba(255, 255, 255, 0.4);
}
/* line:极简描边无底 */
.ct--skin-line {
background: transparent;
border: 2rpx solid var(--ct-line);
box-shadow: none;
}
.ct--skin-line .ct__icon {
background: var(--ct-tint);
}
/* ============================== 尺寸 ============================== */
/* 尺寸档提特异度为 `.ct.ct--sm`/`.ct.ct--lg`(0,2,0),与容器档 `.ct.ct--group`(0,2,0) 同级;
源序在后 → 窄列尺寸档胜出;圆角/内边距统一走 --ct-* 变量,彻底避免互相吞并(R-2 特异度连锁)。 */
.ct.ct--sm {
--ct-radius: 26rpx;
/* 左右内边距对齐 P3 前基线(.floor__tile 的 8rpx):4 列窄卡内容宽须容得下 5 个中文字 */
--ct-pad: 18rpx 8rpx 16rpx;
gap: 8rpx;
}
.ct--sm .ct__icon {
width: 68rpx;
height: 68rpx;
border-radius: 22rpx;
}
.ct--sm .ct__title {
font-size: 24rpx;
/* 窄列防竖排:基线 .floor__tile-name 同款策略(nowrap + 省略号),中文不再逐字断行 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.ct--sm .ct__desc {
font-size: 20rpx;
}
.ct.ct--lg {
--ct-radius: 36rpx;
--ct-pad: 32rpx 30rpx 30rpx;
gap: 12rpx;
}
.ct--lg .ct__icon {
width: 120rpx;
height: 120rpx;
border-radius: 32rpx;
}
.ct--lg .ct__title {
font-size: 32rpx;
}
.ct--lg .ct__desc {
font-size: 24rpx;
}
/* ============================== 异形版式(只改形状/质感,内容走 iconTop 骨架)==============================
统一用 .ct.ct--* 双类(0,2,0),确保赢过 .ct 基础圆角与 .ct--sm/.ct--lg 的圆角。 */
/* 缺角卡:左上直角,其余大圆角 */
.ct.ct--notch {
border-radius: 0 44rpx 44rpx 44rpx;
}
/* 拱形卡:顶部高拱 */
.ct.ct--arch {
border-radius: 64rpx 64rpx 36rpx 36rpx / 96rpx 96rpx 36rpx 36rpx;
}
/* 叶形卡:对角异形 */
.ct.ct--leaf {
border-radius: 48rpx 10rpx 48rpx 10rpx;
}
/* 层叠卡:多层位移投影做出堆叠厚度(不用 z-index / 伪元素,规避小程序层叠风险) */
.ct.ct--stack {
border-radius: 36rpx;
box-shadow:
0 10rpx 0 -2rpx var(--ct-layer),
0 22rpx 0 -8rpx var(--ct-layer-soft),
0 30rpx 40rpx var(--ct-shadow);
}
/* ============================== 版式 6 · tile 瓷片子卡 ==============================
规格硬约束(§2 :99):**不新增渲染分支**,tile 走「else 分支的 iconTop 骨架 + 本修饰 class」。
角标左上 pill / 插画右下,均由模板按 effBadgePos / effArtPos 挂 `.ct__badge--tl` / `.ct__art--br`,
本类只负责「文案左对齐」等骨架差异。 */
.ct--tile {
align-items: flex-start;
}
.ct--tile .ct__title,
.ct--tile .ct__desc {
text-align: left;
max-width: 100%;
}
/* ============================== 未解锁 ============================== */
.ct--lock {
/* 强渐变底色本身饱和,0.72 几乎看不出灰化 → 调狠并略提亮,明显「未解锁」 */
filter: saturate(0.35) brightness(1.04);
}
.ct--lock .ct__title {
color: #bda794;
}
.ct--lock .ct__desc {
color: #c6b3a4;
}
.ct--lock .ct__icon {
background: #f3eee9;
box-shadow: none;
}
.ct--lock .ct__badge {
background: #f3eee9;
color: #bda794;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
/**
* 优惠券卡片(我的优惠券页)。
*
* 两种券型的视觉与文案必须能一眼区分(PRD-02 / T05.3):
* - 免单券(coupon_type=1,kind=2):**金色实心**,按钮「去使用」——它真的能抵扣订单金额;
* - 展示券(coupon_type=2):**描边留白**,按钮「查看详情」——**只说查看,绝不出现
* 「去使用 / 抵扣 / 立减」字样**,否则会让用户以为能抵钱,到下单页落空。
*
* 三种状态(未使用 / 已使用 / 已过期,另含已作废)用灰化 + 角标戳区分,保证可辨别。
*/
import type { UserCoupon } from '@/api/coupon'
import { computed } from 'vue'
import {
COUPON_STATUS_EXPIRED,
COUPON_STATUS_UNUSED,
COUPON_STATUS_USED,
COUPON_STATUS_VOID,
COUPON_TYPE_FREEBIE,
} from '@/api/coupon'
import { fenToYuan } from '@/utils/member-pay'
const props = defineProps<{ coupon: UserCoupon }>()
const emit = defineEmits<{ use: [coupon: UserCoupon], detail: [coupon: UserCoupon] }>()
/** 免单券:金色实心 + 去使用;展示券:描边 + 查看详情 */
const isFreebie = computed(() => props.coupon.coupon_type === COUPON_TYPE_FREEBIE)
/** 满减(kind=1)才展示面额数字;免单(kind=2)直接写「免单」 */
const isCut = computed(() => props.coupon.kind !== 2)
const usable = computed(() => props.coupon.status === COUPON_STATUS_UNUSED)
/** 面额主文案 */
const amountText = computed(() => {
if (!isCut.value)
return '免单'
const v = props.coupon.value || 0
return v > 0 ? `¥${fenToYuan(v)}` : '优惠'
})
/** 门槛文案 */
const thresholdText = computed(() => {
const t = props.coupon.threshold_cents || 0
return t > 0 ? `满 ¥${fenToYuan(t)} 可用` : '无门槛'
})
/** 状态角标(未使用不展示) */
const statusBadge = computed(() => {
switch (props.coupon.status) {
case COUPON_STATUS_USED:
return '已使用'
case COUPON_STATUS_EXPIRED:
return '已过期'
case COUPON_STATUS_VOID:
return '已作废'
default:
return ''
}
})
const expireText = computed(() => (props.coupon.expire_at ? `有效期至 ${props.coupon.expire_at}` : '长期有效'))
function onAction() {
if (!usable.value)
return
if (isFreebie.value)
emit('use', props.coupon)
else
emit('detail', props.coupon)
}
</script>
<template>
<view class="cc" :class="{ 'cc--freebie': isFreebie, 'cc--display': !isFreebie, 'cc--off': !usable }">
<!-- 左:面额 -->
<view class="cc__left">
<text class="cc__amount" :class="{ 'cc__amount--word': !isCut }">
{{ amountText }}
</text>
<text class="cc__threshold">{{ thresholdText }}</text>
</view>
<!-- 中缝孔:券的视觉切口 -->
<view class="cc__notch" />
<!-- 右:券信息 -->
<view class="cc__body">
<view class="cc__head">
<text class="cc__title">{{ coupon.title || '优惠券' }}</text>
<view v-if="statusBadge" class="cc__stamp">{{ statusBadge }}</view>
</view>
<text class="cc__sub">{{ coupon.sub_title || (isFreebie ? '下单时可直接抵扣' : '活动权益凭证') }}</text>
<text class="cc__expire">{{ expireText }}</text>
<view class="cc__foot">
<view class="cc__type-tag" :class="{ 'cc__type-tag--freebie': isFreebie }">
{{ isFreebie ? '免单券' : '展示券' }}
</view>
<view
v-if="usable"
class="cc__btn press"
:class="{ 'cc__btn--freebie': isFreebie }"
hover-class="press-active"
@tap="onAction"
>
<text>{{ isFreebie ? '去使用' : '查看详情' }}</text>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.cc {
position: relative;
display: flex;
align-items: stretch;
border-radius: 28rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
/* 免单券:金色实心(可抵扣,视觉最重) */
.cc--freebie {
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 52%, #d89a35 100%);
box-shadow: 0 14rpx 30rpx rgba(216, 154, 53, 0.32);
}
/* 展示券:描边留白(仅展示,视觉更轻,不暗示能抵钱) */
.cc--display {
background: #ffffff;
border: 3rpx solid #ffd9c2;
box-shadow: 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
}
.cc--off {
opacity: 0.55;
filter: grayscale(0.85);
}
.cc__left {
flex-shrink: 0;
width: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 30rpx 12rpx;
}
.cc--display .cc__left {
background: #fff9f2;
}
.cc__amount {
font-size: 50rpx;
font-weight: 800;
line-height: 1.05;
color: #3d2413;
}
.cc__amount--word {
font-size: 40rpx;
}
.cc--display .cc__amount {
color: #e8531a;
}
.cc__threshold {
font-size: 20rpx;
color: rgba(61, 36, 19, 0.7);
}
.cc--display .cc__threshold {
color: #b3907c;
}
/* 中缝切口:两个半圆 + 虚线 */
.cc__notch {
flex-shrink: 0;
width: 2rpx;
margin: 18rpx 0;
border-left: 2rpx dashed rgba(61, 36, 19, 0.28);
position: relative;
}
.cc--display .cc__notch {
border-left-color: #ffd9c2;
}
.cc__notch::before,
.cc__notch::after {
content: '';
position: absolute;
left: -14rpx;
width: 28rpx;
height: 28rpx;
border-radius: 50%;
}
.cc__notch::before {
top: -30rpx;
background: #fff9f2;
}
.cc__notch::after {
bottom: -30rpx;
background: #fff9f2;
}
.cc__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 26rpx 28rpx;
}
.cc__head {
display: flex;
align-items: center;
gap: 12rpx;
}
.cc__title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 800;
color: #3d2a1e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cc__stamp {
flex-shrink: 0;
font-size: 20rpx;
font-weight: 700;
color: #9a7b65;
border: 2rpx solid #d9c3b2;
border-radius: 8rpx;
padding: 2rpx 12rpx;
transform: rotate(-6deg);
}
.cc__sub {
font-size: 23rpx;
color: #9a7b65;
}
.cc__expire {
font-size: 21rpx;
color: #b3907c;
}
.cc__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.cc__type-tag {
font-size: 20rpx;
padding: 4rpx 16rpx;
border-radius: 999rpx;
color: #9a7b65;
background: #f6efe8;
}
.cc__type-tag--freebie {
color: #3d2413;
background: rgba(255, 255, 255, 0.6);
font-weight: 700;
}
.cc__btn {
flex-shrink: 0;
padding: 12rpx 40rpx;
border-radius: 999rpx;
font-size: 25rpx;
font-weight: 700;
color: #3d2413;
background: rgba(255, 255, 255, 0.66);
border: 2rpx solid rgba(61, 36, 19, 0.18);
}
.cc__btn--freebie {
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
border-color: transparent;
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.32);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,143 @@
<script setup lang="ts">
/**
* 首页「工具库」pane。
*
* 结构(承前 refresher 下沉 + N5):**根元素即 scroll-view**,下拉只刷新**工具域**
* (工具表 + 工作台)——**不触碰额度**(下拉不出现 /quota/my)。
*/
import type { ToolDef } from '@/config/tools'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { apiToolsUsed } from '@/api/user'
import CardTile from '@/components/card/CardTile.vue'
import EmptyState from '@/components/EmptyState.vue'
import Sticker from '@/components/Sticker.vue'
import PaneRefresher from '@/components/home/PaneRefresher.vue'
import { searchTools, TOOL_REGISTRY } from '@/config/tools'
import { useCardStyleStore } from '@/stores/cardStyle'
import { useToolsStore } from '@/stores/tools'
import { moduleColor, toolPalette } from '@/utils/palette'
import { useUserStore } from '@/stores/user'
import { REFRESH_BG, REFRESH_THRESHOLD, usePaneRefresh } from '@/utils/pane-refresh'
import { toolIllustration } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
import { showUnlockGuide } from '@/utils/unlock-guide'
const props = defineProps<{ active: boolean }>()
const toolsStore = useToolsStore()
const userStore = useUserStore()
const cardStyle = useCardStyleStore()
/** 色系轮转表(与 PaneWorkbench 一致):无显式配置时保证宫格/楼层色彩多样 */
const TONES = ['gold', 'blue', 'mint', 'rose', 'violet'] as const
/** 工具卡色系哈希 */
function toolTone(key: string) {
let h = 0
for (let i = 0; i < key.length; i++)
h = (h * 31 + key.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** 工具卡样式:版式/皮肤走「区块 + 该工具」逐入口解析;色系默认按 key 哈希,显式配置则以其为准 */
function toolCard(key: string, section: string) {
const targetKey = `tool.${key}`
const s = cardStyle.styleOf(targetKey, section)
const hasTone = !!cardStyle.explicitOf(targetKey, section).tone
return { template: s.template, variant: s.variant, tone: hasTone ? s.tone : toolTone(key) }
}
/** 模块楼层容器样式(分组容器卡)
* ⚠️ 楼层是**容器卡**:`CardTile` 只有 group 版式会渲染默认插槽,其余版式一律丢弃插槽内容
* → 楼层里的工具小卡会整段消失(角标还写着「N 个工具」,下面却空白)。
* store 已按区块白名单(`library.floor → ['group']`)兜底钳制,这里再压一层,
* 保证即使有人绕过 store 取样式,楼层也永远拿得到 group。 */
function floorCard() {
return { ...cardStyle.styleOf('section.library.floor'), template: 'group' }
}
const { refreshing, bubbleScale, onPulling, onRestore, run } = usePaneRefresh()
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
const keyword = ref('')
let loadedOnce = false
/** 工具库域刷新:仅工具表 + 工作台(**不含额度**) */
async function load() {
try {
await userStore.ensureLogin()
await toolsStore.fetchAll()
}
catch {
/* 静默 */
}
}
function refresh() {
return run(load)
}
watch(() => props.active, (v) => {
if (v && !loadedOnce) {
loadedOnce = true
load()
}
}, { immediate: true })
defineExpose({ refresh })
/** 热词:热推工具名,兜底常用词 */
const hotWords = computed(() => {
const names = toolsStore.tools.filter(t => t.is_hot).map(t => t.name)
return (names.length ? names : ['图片压缩', '去除背景', '格式转换', '修改尺寸']).slice(0, 6)
})
/** 模块楼层:每个模块一张彩色楼层卡 */
/** 模块楼层:每个模块一张分组容器卡(视觉由 CardTile group 版式决定) */
const moduleFloors = computed(() => {
const serverMap = new Map(toolsStore.tools.map(t => [t.tool_key, t]))
const modules = toolsStore.modules.length
? toolsStore.modules
: [{ module_key: 'image', name: '图片处理', locked: true }]
return modules
.map((m, i) => ({
.map(m => ({
...m,
tools: TOOL_REGISTRY.filter(d => d.module === m.module_key).map(d => ({
...d,
locked: serverMap.get(d.key)?.locked ?? true,
})),
color: moduleColor(m.module_key, i, m.locked),
}))
.filter(m => m.tools.length)
})
/**
* 当前激活的模块 tab。
* `all` = 「全部」(默认,等价改造前的现状:所有模块楼层都在)。
* 其余值 = 某个模块的 module_key,仅显示该模块楼层。
*/
const activeModule = ref('all')
/** 分组 tab 项:「全部」固定第一,其后为各模块(保留 key 便于过滤、保留 locked 便于弱化标记) */
const moduleTabs = computed(() => [
{ key: 'all', name: '全部', locked: false },
...moduleFloors.value.map(m => ({ key: m.module_key, name: m.name, locked: !!m.locked })),
])
/** 当前 tab 对应的楼层:`all` → 全部;否则精确匹配该模块 */
const visibleFloors = computed(() => {
if (activeModule.value === 'all')
return moduleFloors.value
return moduleFloors.value.filter(m => m.module_key === activeModule.value)
})
/**
* 兜底:数据刷新后若当前 tab 指向的模块已不存在(例如后端删除模块),
* 自动回落到「全部」,避免出现"点了 tab 之后列表空白"。
*/
watch(moduleFloors, (floors) => {
if (activeModule.value !== 'all' && !floors.some(m => m.module_key === activeModule.value))
activeModule.value = 'all'
})
const FLOOR_STICKERS = ['rocket', 'rainbow', 'bolt', 'cloud', 'star', 'sparkle'] as const
/** 搜索结果(注册表 + 服务端锁定态合并) */
@@ -76,7 +175,23 @@ function openTool(path: string, toolKey: string, locked: boolean) {
</script>
<template>
<view>
<scroll-view
scroll-y
class="pane"
:show-scrollbar="false"
:refresher-enabled="true"
refresher-default-style="none"
:refresher-threshold="REFRESH_THRESHOLD"
:refresher-background="REFRESH_BG"
:refresher-triggered="refreshing"
@refresherpulling="onPulling"
@refresherrestore="onRestore"
@refresherrefresh="refresh"
>
<template #refresher>
<PaneRefresher :on="refreshing" :scale="bubbleScale" />
</template>
<!-- 渐变头区 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__row">
@@ -122,6 +237,22 @@ function openTool(path: string, toolKey: string, locked: boolean) {
</view>
</scroll-view>
</view>
<!-- 模块分组 tab:点某模块只显示该模块;「全部」恢复"所有模块都在" -->
<view v-if="!keyword" class="modtabs">
<scroll-view scroll-x class="modtabs__scroll" :show-scrollbar="false" :enable-flex="true">
<view
v-for="tab in moduleTabs"
:key="tab.key"
class="modtabs__chip press"
:class="{ 'is-active': activeModule === tab.key, 'is-locked': tab.locked }"
hover-class="press-active"
@tap="activeModule = tab.key"
>
<text>{{ tab.name }}</text>
</view>
</scroll-view>
</view>
</view>
<!-- 搜索结果宫格 -->
@@ -130,21 +261,12 @@ function openTool(path: string, toolKey: string, locked: boolean) {
<view
v-for="(t, i) in list"
:key="t.key"
class="tb-tile tb-rise press"
:class="{ 'tb-tile--lock': t.locked }"
class="cttap tb-rise press"
:style="{ animationDelay: `${Math.min(i, 11) * 40}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<view class="tb-tile__icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="46rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="tb-tile__name">
{{ t.name }}
</text>
<CardTile v-bind="toolCard(t.key, 'library.grid')" :icon="t.icon" :title="t.name" :locked="t.locked" :illustration="toolIllustration(t.key)" size="sm" />
</view>
</view>
<EmptyState
@@ -155,53 +277,38 @@ function openTool(path: string, toolKey: string, locked: boolean) {
/>
</template>
<!-- 模块楼层卡 -->
<!-- 模块楼层卡(分组容器卡:白底容器 + 模块名 + 工具子卡网格) -->
<template v-else>
<view
v-for="(m, i) in moduleFloors"
<CardTile
v-for="(m, i) in visibleFloors"
:key="m.module_key"
v-bind="floorCard()"
class="floor tb-rise"
:style="{ background: m.color.soft, borderColor: m.color.bg, animationDelay: `${Math.min(i, 5) * 70}ms` }"
:style="{ animationDelay: `${Math.min(i, 5) * 70}ms` }"
:title="m.name"
:badge="m.locked ? '未解锁' : `${m.tools.length} 个工具`"
:illustration="FLOOR_STICKERS[i % FLOOR_STICKERS.length]"
:grid="false"
:columns="4"
>
<view class="floor__head">
<Sticker :type="FLOOR_STICKERS[i % FLOOR_STICKERS.length]" :size="64" float :delay="i * 260" />
<view class="floor__titles">
<text class="floor__name" :style="{ color: m.locked ? '#BDA794' : m.color.deep }">
{{ m.name }}
</text>
<text class="floor__count">
{{ m.tools.length }} 个工具
</text>
</view>
<view v-if="m.locked" class="floor__lock">
未解锁
</view>
</view>
<view class="floor__grid">
<!-- P5:自管 scoped 网格(同工作台 .wb-grid 的已被真机验证的写法),替代组件内联 style 网格 -->
<view class="floor-grid">
<view
v-for="t in m.tools"
:key="t.key"
class="floor__tile press"
class="cttap press"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="floor__tile-lock">
未解锁
</view>
<view class="floor__tile-icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="42rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="floor__tile-name" :style="{ color: t.locked ? '#BDA794' : '#3D2A1E' }">
{{ t.name }}
</text>
<CardTile v-bind="toolCard(t.key, 'library.tile')" :icon="t.icon" :title="t.name" :locked="t.locked" :illustration="toolIllustration(t.key)" size="sm" />
</view>
</view>
</view>
</CardTile>
</template>
<view class="foot" />
</view>
</view>
</scroll-view>
</template>
<style scoped>
@@ -271,90 +378,49 @@ function openTool(path: string, toolKey: string, locked: boolean) {
font-size: 24rpx;
}
/* 模块楼层卡 */
.floor {
position: relative;
overflow: hidden;
border: 2rpx solid transparent;
border-radius: 32rpx;
padding: 26rpx 24rpx 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
/* 模块分组 tab:可横滑胶囊行,选中实心橙底白字 */
.modtabs {
padding: 12rpx 12rpx 4rpx;
}
.floor__head {
display: flex;
.modtabs__scroll {
width: 100%;
white-space: nowrap;
}
.modtabs__chip {
display: inline-flex;
align-items: center;
gap: 18rpx;
margin-bottom: 22rpx;
}
.floor__titles {
flex: 1;
min-width: 0;
}
.floor__name {
display: block;
font-size: 32rpx;
font-weight: 800;
}
.floor__count {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #9a7b65;
}
.floor__lock {
background: #ffffff;
color: #bda794;
font-size: 22rpx;
padding: 8rpx 22rpx;
flex-shrink: 0;
background: #fff1e8;
border: 1rpx solid #ffd4b8;
color: #e8531a;
border-radius: 999rpx;
border: 2rpx dashed #e3d5c8;
padding: 10rpx 28rpx;
margin-right: 14rpx;
font-size: 24rpx;
font-weight: 600;
}
.floor__grid {
.modtabs__chip.is-active {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.modtabs__chip.is-locked {
opacity: 0.6;
}
/* 模块楼层卡:容器视觉由 CardTile group 版式决定(P3),此处仅保留楼层间距与进场 */
.floor {
margin-bottom: 24rpx;
}
/* P5 修复竖排:楼层子卡改用调用方自管 scoped 网格(CardTile :grid="false" 后渲染裸 slot)。
写法与工作台 .wb-grid 一致(repeat(4,1fr) + gap),scoped class 在 MP 已被真机验证可靠;
组件内联 style 的 repeat() 网格在用户 MP 环境不生效导致逐字竖排,故弃用。 */
.floor-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.floor__tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx 8rpx 20rpx;
box-shadow: 0 8rpx 20rpx rgba(107, 47, 15, 0.07);
}
.floor__tile-lock {
position: absolute;
top: 0;
right: 0;
background: #f3eee9;
color: #bda794;
font-size: 16rpx;
padding: 4rpx 12rpx;
border-radius: 0 24rpx 0 16rpx;
}
.floor__tile-icon {
width: 84rpx;
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), inset 0 -8rpx 14rpx rgba(255, 255, 255, 0.85),
0 6rpx 14rpx rgba(107, 47, 15, 0.1);
}
.floor__tile-name {
font-size: 24rpx;
font-weight: 600;
max-width: 136rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.foot {
height: calc(220rpx + env(safe-area-inset-bottom, 0px));

View File

@@ -1,19 +1,81 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { apiBindAccount, apiFeedbackSubmit } from '@/api/user'
import Confetti from '@/components/Confetti.vue'
/**
* 首页「我的」pane。
*
* 结构(承前 refresher 下沉 + N5):**根元素即 scroll-view**,下拉只刷新**我的域**
* (登录态 + 等级 + 工具表)——**不触碰额度**(下拉不出现 /quota/my)。
*
* 底部抽屉(绑定 H5 账号 / 意见反馈)**不在本组件内渲染**:它们被上提到
* `pages/index/index.vue` 的**页面根级**(见 components/mine/MineDrawers.vue 与 N5 真诊断)。
* 本组件只通过 `emit('feedback' | 'bind')` 请求打开,避免抽屉被困在 pane 的 scroll-view
* 层叠/裁剪链里而被悬浮 TabBar 压住。
*/
import { computed, ref, watch } from 'vue'
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 { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { presetOf } from '@/utils/avatar'
import { REFRESH_BG, REFRESH_THRESHOLD, usePaneRefresh } from '@/utils/pane-refresh'
import { heroPadTop } from '@/utils/safearea'
const props = defineProps<{ active: boolean }>()
const emit = defineEmits<{ feedback: [], bind: [], cardstyle: [] }>()
const toolsStore = useToolsStore()
const userStore = useUserStore()
/** 「我的」页菜单行图标块配色(回滚:还原改造前的 CELL_BG/CELL_COLOR 语义) */
const CELL_ICON = {
workbench: { bg: '#FFE8DC', color: '#E8531A' },
orders: { bg: '#E3F2FF', color: '#1677D9' },
coupons: { bg: '#FFF4D6', color: '#B57F00' },
cardstyle: { bg: '#ECE2FF', color: '#5B2E9E' },
feedback: { bg: '#FFF4D6', color: '#B57F00' },
bind: { bg: '#E3F2FF', color: '#1677D9' },
version: { bg: '#DFF7F0', color: '#0FA385' },
} as const
const { refreshing, bubbleScale, onPulling, onRestore, run } = usePaneRefresh()
/** 功能入口(笔记等)跳转:入口独立于工具库楼层,见 config/features.ts */
function goFeature(path: string) {
uni.navigateTo({ url: path })
}
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
let loadedOnce = false
/** 我的域刷新:登录态 + 等级 + 工具表(**不含额度**) */
async function load() {
try {
await userStore.ensureLogin()
await Promise.all([
toolsStore.fetchAll().catch(() => {}),
userStore.refreshLevel().catch(() => {}),
])
}
catch {
/* 静默 */
}
}
function refresh() {
return run(load)
}
watch(() => props.active, (v) => {
if (v && !loadedOnce) {
loadedOnce = true
load()
}
}, { immediate: true })
defineExpose({ refresh })
const levelName = computed(() => userStore.level?.name || '未授权')
const unlockedModules = computed(() =>
toolsStore.modules.filter(m => !m.locked).map(m => m.name),
@@ -25,74 +87,65 @@ const stats = computed(() => [
{ label: '已解锁模块', value: toolsStore.modules.filter(m => !m.locked).length },
])
/** 菜单图标块的淡色轮换 */
const CELL_BGS = ['#FFE8DC', '#FFF4D6', '#DFF7F0', '#E3F2FF'] as const
const feedbackVisible = ref(false)
const feedbackContent = ref('')
const feedbackContact = ref('')
const submitting = ref(false)
/** 彩带触发计数 */
const burst = ref(0)
/** 绑定 H5 账号(微信端把独立 H5 账号合并进来) */
const bindVisible = ref(false)
const bindUsername = ref('')
const bindPassword = ref('')
const binding = ref(false)
async function submitFeedback() {
if (!feedbackContent.value.trim()) {
uni.showToast({ title: '请填写反馈内容', icon: 'none' })
return
}
submitting.value = true
try {
await apiFeedbackSubmit(feedbackContent.value.trim(), feedbackContact.value.trim())
uni.showToast({ title: '感谢反馈', icon: 'success' })
feedbackVisible.value = false
feedbackContent.value = ''
feedbackContact.value = ''
burst.value++
}
finally {
submitting.value = false
}
}
async function submitBind() {
const name = bindUsername.value.trim()
const pass = bindPassword.value
if (!name || !pass) {
uni.showToast({ title: '请填写 H5 账号的用户名和密码', icon: 'none' })
return
}
binding.value = true
try {
await apiBindAccount(name, pass)
uni.showToast({ title: '绑定成功,数据已合并', icon: 'success' })
bindVisible.value = false
bindUsername.value = ''
bindPassword.value = ''
burst.value++
await Promise.all([userStore.ensureLogin(), toolsStore.fetchAll()])
}
finally {
binding.value = false
}
}
/** 登录中(MP 静默登录):用于按钮 loading 与防抖 */
const loggingIn = ref(false)
function goLogin() {
uni.navigateTo({ url: '/pages/account/login' })
}
/** 已登录但 user 信息暂缺(如 /user/info 异常)时显示兜底昵称 */
const displayName = computed(() => userStore.user?.nickname || (userStore.logged ? '微信用户' : '未登录'))
/**
* 未登录时点击用户卡 / 登录按钮:
* - MP:**静默登录**(wx.login → /user/wx-login),不跳页、不报错,3 秒内变成已登录;
* - H5:跳已有登录页(H5 无法静默登录)。
* 失败可重试,且给出可读文案。
*/
async function startLogin() {
if (loggingIn.value)
return
// #ifdef H5
goLogin()
return
// #endif
// #ifndef H5
loggingIn.value = true
try {
await userStore.loginWx()
await Promise.all([
toolsStore.fetchAll().catch(() => {}),
userStore.refreshLevel().catch(() => {}),
])
uni.showToast({ title: '登录成功', icon: 'success' })
}
catch (e) {
uni.showModal({
title: '登录失败',
content: (e as Error)?.message || '网络异常,请稍后重试',
showCancel: false,
confirmText: '重试',
success: (res) => {
if (res.confirm)
startLogin()
},
})
}
finally {
loggingIn.value = false
}
// #endif
}
/** 用户卡 → 个人信息编辑(未登录时引导登录) */
/** 已登录但 user 信息暂缺(如 /user/info 异常)时显示兜底昵称 */
const displayName = computed(() => {
if (!userStore.logged)
return '登录 / 注册'
return userStore.user?.nickname || '微信用户'
})
/** 用户卡 → 未登录先登录;已登录进个人信息编辑 */
function goProfile() {
if (!userStore.logged) {
goLogin()
startLogin()
return
}
uni.navigateTo({ url: '/pages/mine/profile' })
@@ -111,6 +164,16 @@ function goMember() {
uni.navigateTo({ url: '/pkg-member/member/index' })
}
/** 我的订单 */
function goOrders() {
uni.navigateTo({ url: '/pkg-member/member/orders' })
}
/** 我的优惠券 */
function goCoupons() {
uni.navigateTo({ url: '/pkg-member/coupon/index' })
}
function logout() {
uni.showModal({
title: '退出登录',
@@ -124,7 +187,23 @@ function logout() {
</script>
<template>
<view>
<scroll-view
scroll-y
class="pane"
:show-scrollbar="false"
:refresher-enabled="true"
refresher-default-style="none"
:refresher-threshold="REFRESH_THRESHOLD"
:refresher-background="REFRESH_BG"
:refresher-triggered="refreshing"
@refresherpulling="onPulling"
@refresherrestore="onRestore"
@refresherrefresh="refresh"
>
<template #refresher>
<PaneRefresher :on="refreshing" :scale="bubbleScale" />
</template>
<!-- 渐变头区 + 用户卡 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="user tb-rise press" hover-class="press-active" @tap="goProfile">
@@ -153,7 +232,7 @@ function logout() {
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 数据总览 -->
<!-- 数据总览(回滚:白卡 + 三段统计,无卡片风格) -->
<view class="tb-card stats tb-rise">
<view v-for="(s, i) in stats" :key="s.label" class="stats__item" :class="{ 'stats__item--div': i > 0 }">
<CountUp :value="s.value" class="stats__value" />
@@ -163,11 +242,11 @@ function logout() {
</view>
</view>
<!-- 菜单 -->
<!-- 菜单(回滚:单张白卡 + 行列表) -->
<view class="tb-card cells tb-rise" style="animation-delay: 60ms">
<view class="cell press" hover-class="press-active" @tap="goEditWorkbench">
<view class="cell__icon" :style="{ background: CELL_BGS[0] }">
<wd-icon name="edit-outline" size="34rpx" color="#E8531A" />
<view class="cell__icon" :style="{ background: CELL_ICON.workbench.bg }">
<wd-icon name="edit-outline" size="34rpx" :color="CELL_ICON.workbench.color" />
</view>
<text class="cell__label">
我的工作台
@@ -177,6 +256,67 @@ function logout() {
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<!-- 功能入口(笔记等):独立于工具库楼层,来源 config/features.ts -->
<view
v-for="fe in FEATURE_ENTRIES"
:key="fe.key"
class="cell press"
hover-class="press-active"
@tap="goFeature(fe.path)"
>
<view class="cell__icon" :style="{ background: fe.bg }">
<wd-icon :name="fe.icon" size="34rpx" :color="fe.color" />
</view>
<text class="cell__label">
{{ fe.name }}
</text>
<text class="cell__value">
{{ fe.desc }}
</text>
<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 }">
<wd-icon name="layers" size="34rpx" :color="CELL_ICON.cardstyle.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="goMember">
<view class="cell__icon cell__icon--vip">
<text class="cell__icon-text">
@@ -191,19 +331,21 @@ function logout() {
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<view class="cell press" hover-class="press-active" @tap="feedbackVisible = true">
<view class="cell__icon" :style="{ background: CELL_BGS[1] }">
<wd-icon name="chat" size="34rpx" color="#B57F00" />
<view class="cell press" hover-class="press-active" @tap="emit('feedback')">
<view class="cell__icon" :style="{ background: CELL_ICON.feedback.bg }">
<wd-icon name="chat" size="34rpx" :color="CELL_ICON.feedback.color" />
</view>
<text class="cell__label">
意见反馈
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="userStore.logged" class="cell press" hover-class="press-active" @tap="bindVisible = true">
<view class="cell__icon" :style="{ background: CELL_BGS[3] }">
<wd-icon name="link" size="34rpx" color="#1677D9" />
<view v-if="userStore.logged" class="cell press" hover-class="press-active" @tap="emit('bind')">
<view class="cell__icon" :style="{ background: CELL_ICON.bind.bg }">
<wd-icon name="link" size="34rpx" :color="CELL_ICON.bind.color" />
</view>
<text class="cell__label">
绑定 H5 账号
@@ -214,9 +356,10 @@ function logout() {
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<!-- #endif -->
<view class="cell">
<view class="cell__icon" :style="{ background: CELL_BGS[2] }">
<wd-icon name="setting" size="34rpx" color="#0FA385" />
<view class="cell__icon" :style="{ background: CELL_ICON.version.bg }">
<wd-icon name="setting" size="34rpx" :color="CELL_ICON.version.color" />
</view>
<text class="cell__label">
版本
@@ -230,75 +373,14 @@ function logout() {
<view v-if="userStore.logged" class="logout press tb-rise" style="animation-delay: 100ms" hover-class="press-active" @tap="logout">
<text>退出登录</text>
</view>
<!-- H5 未登录:引导去登录/注册 -->
<!-- #ifdef H5 -->
<view v-else class="login-cta press tb-rise" style="animation-delay: 100ms" hover-class="press-active" @tap="goLogin">
<text>登录 / 注册</text>
<!-- 未登录:MP 静默登录 / H5 跳已有登录页 -->
<view v-else class="login-cta press tb-rise" style="animation-delay: 100ms" hover-class="press-active" @tap="startLogin">
<text>{{ loggingIn ? '登录中…' : '登录 / 注册' }}</text>
</view>
<!-- #endif -->
<view class="foot" />
</view>
<!-- 彩带庆祝 -->
<Confetti :burst="burst" />
<!-- 绑定 H5 账号弹层(微信端合并账号) -->
<wd-popup v-model="bindVisible" position="bottom" custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;">
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
绑定 H5 账号
</text>
<text class="fb__sub">
合并后小程序与 H5 共用同一份数据,H5 端可用该账号密码登录
</text>
<view class="fb__box">
<wd-input v-model="bindUsername" placeholder="H5 账号的用户名" no-border custom-class="fb__input" />
</view>
<view class="fb__box">
<wd-input v-model="bindPassword" password placeholder="H5 账号的密码" no-border custom-class="fb__input" />
</view>
<wd-button
type="primary"
block
:loading="binding"
custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="submitBind"
>
绑定并合并数据
</wd-button>
</view>
</wd-popup>
<!-- 反馈弹层 -->
<wd-popup v-model="feedbackVisible" position="bottom" custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;">
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
意见反馈
</text>
<text class="fb__sub">
说说你想用的工具,或遇到的问题
</text>
<view class="fb__box">
<wd-textarea
v-model="feedbackContent"
placeholder="写下你的想法…"
:maxlength="500"
show-word-limit
custom-class="fb__textarea"
/>
</view>
<view class="fb__box">
<wd-input v-model="feedbackContact" placeholder="联系方式(选填)" no-border custom-class="fb__input" />
</view>
<wd-button type="primary" block :loading="submitting" custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);" @click="submitFeedback">
提交反馈
</wd-button>
</view>
</wd-popup>
</view>
</scroll-view>
</template>
<style scoped>
@@ -368,7 +450,7 @@ function logout() {
border-radius: 999rpx;
}
/* 数据总览 */
/* 数据总览(回滚:白卡内的三段统计) */
.stats {
display: flex;
padding: 30rpx 0;
@@ -394,7 +476,7 @@ function logout() {
color: #9a7b65;
}
/* 菜单 */
/* 菜单(回滚:白卡 + 行列表,无卡片风格) */
.cells {
padding: 8rpx 0;
}
@@ -403,7 +485,7 @@ function logout() {
align-items: center;
gap: 22rpx;
padding: 26rpx 28rpx;
min-height: 44px;
min-height: 88rpx;
box-sizing: border-box;
}
.cell__icon {
@@ -415,7 +497,6 @@ function logout() {
align-items: center;
justify-content: center;
}
/* 会员图标:金色渐变 + 文字标记,与其它模块的淡色块区分开 */
.cell__icon--vip {
background: linear-gradient(140deg, #ffe9b8, #ffd77a);
}
@@ -461,39 +542,7 @@ function logout() {
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
}
/* 反馈弹层 */
.fb {
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 20rpx 32rpx calc(36rpx + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 20rpx;
}
.fb__grip {
align-self: center;
width: 72rpx;
height: 8rpx;
border-radius: 999rpx;
background: #ffe3d3;
margin-bottom: 4rpx;
}
.fb__title {
font-size: 34rpx;
font-weight: 800;
color: #3d2a1e;
}
.fb__sub {
margin-top: -12rpx;
font-size: 24rpx;
color: #9a7b65;
}
.fb__box {
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 6rpx 20rpx;
}
/* 反馈/绑定抽屉已上提到页面根级(components/mine/MineDrawers.vue),样式随之外移 */
.foot {
height: calc(200rpx + env(safe-area-inset-bottom, 0px));
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
/**
* 首页 pane 的下拉刷新视觉(三气泡 + 文案)。
*
* 样式 `.tb-refresh*` 是全局 token(定义在 App.vue),本组件只负责结构,
* 供三个 pane 各自的 scroll-view `#refresher` 插槽复用,避免三处重复标记。
*/
defineProps<{
/** 是否处于刷新中 */
on: boolean
/** 气泡缩放(下拉进度 0.5→1) */
scale: number
}>()
</script>
<template>
<view class="tb-refresh" :class="{ 'tb-refresh--on': on }">
<view class="tb-refresh__bubbles" :style="{ transform: `scale(${scale.toFixed(3)})` }">
<view class="tb-refresh__bubble" />
<view class="tb-refresh__bubble" />
<view class="tb-refresh__bubble" />
</view>
<text class="tb-refresh__text">{{ on ? '正在刷新…' : '' }}</text>
</view>
</template>

View File

@@ -1,25 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* 首页「工作台」pane。
*
* 结构(承前 refresher 下沉 + N5):**根元素即 scroll-view**,持有自己的下拉刷新,
* 只暴露 `refresh()` 供首页在 onShow 时按域刷新。刷新域 = 工具表 + 工作台 + **额度(/quota/my)**
* ——工作台需要展示额度,这是三 pane 里唯一会打 /quota/my 的域。
*/
import { computed, ref, watch } from 'vue'
import { apiToolsUsed } from '@/api/user'
import CardTile from '@/components/card/CardTile.vue'
import EmptyState from '@/components/EmptyState.vue'
import Sticker from '@/components/Sticker.vue'
import PaneRefresher from '@/components/home/PaneRefresher.vue'
import VipCard from '@/components/vip/VipCard.vue'
import { FEATURE_ENTRIES } from '@/config/features'
import { TOOL_REGISTRY } from '@/config/tools'
import { useCardStyleStore } from '@/stores/cardStyle'
import { useQuotaStore } from '@/stores/quota'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { toolPalette } from '@/utils/palette'
import { REFRESH_BG, REFRESH_THRESHOLD, usePaneRefresh } from '@/utils/pane-refresh'
import { toolIllustration } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
import { showUnlockGuide } from '@/utils/unlock-guide'
const props = defineProps<{ ready: boolean }>()
const props = defineProps<{ active: boolean }>()
const emit = defineEmits<{ library: [] }>()
const toolsStore = useToolsStore()
const userStore = useUserStore()
const quotaStore = useQuotaStore()
const cardStyle = useCardStyleStore()
const { refreshing, bubbleScale, onPulling, onRestore, run } = usePaneRefresh()
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
/** 首屏数据是否就绪(失败也置 true,展示空态而非永久 loading) */
const ready = ref(false)
let loadedOnce = false
/** 工作台域刷新:工具表 + 工作台 + 额度(额度为工作台独有) */
async function load() {
try {
await userStore.ensureLogin()
await toolsStore.fetchAll()
await quotaStore.fetchMine().catch(() => {})
}
catch {
/* 静默:展示空态 */
}
finally {
ready.value = true
}
}
/** 供首页 onShow 调用 */
function refresh() {
return run(load)
}
watch(() => props.active, (v) => {
if (v && !loadedOnce) {
loadedOnce = true
load()
}
}, { immediate: true })
defineExpose({ refresh })
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6)
@@ -58,11 +107,70 @@ function goEdit() {
uni.navigateTo({ url: '/pages/workbench/edit' })
}
/**
* 功能入口(笔记 / 年会查座)跳转:入口来源 `config/features.ts`,**不走** TOOL_REGISTRY
* (后者会被后端 tools 表判 locked 而静默锁死,见 features.ts 顶部注释)。
* 首页 = 本工作台 pane,故此处渲染即同时满足「首页」与「功能」两处可见(P1-2)。
*/
function goFeature(path: string) {
uni.navigateTo({ url: path })
}
const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
const HOT_STICKERS = ['sparkle', 'bolt', 'cloud', 'star', 'rocket', 'rainbow'] as const
/** 色系轮转表(P3) */
const TONES = ['gold', 'blue', 'mint', 'rose', 'violet'] as const
/** 工具卡色系哈希:无显式配置时保证宫格色彩多样 */
function toolTone(key: string) {
let h = 0
for (let i = 0; i < key.length; i++)
h = (h * 31 + key.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** 工具卡样式:版式/皮肤取「区块 + 该工具」逐入口解析;色系默认按 key 哈希,显式配置则以其为准 */
function toolCard(key: string, section: string) {
const targetKey = `tool.${key}`
const s = cardStyle.styleOf(targetKey, section)
const hasTone = !!cardStyle.explicitOf(targetKey, section).tone
return { template: s.template, variant: s.variant, tone: hasTone ? s.tone : toolTone(key) }
}
/** 功能入口样式(feature.<key>):版式/皮肤/色系按「区块 + 该入口」解析;
色系默认取入口自带 tone(还原改造前逐入口配色),用户显式配置则以其为准 */
function featureCard(key: string, section: string) {
const s = cardStyle.styleOf(`feature.${key}`, section)
const hasTone = !!cardStyle.explicitOf(`feature.${key}`, section).tone
const fallback = FEATURE_ENTRIES.find(f => f.key === key)?.tone
return { ...s, tone: hasTone ? s.tone : (fallback || s.tone) }
}
/** 通栏主卡:按「区块 + 该工具」逐入口解析(无显式时用区块默认,不做色系哈希,保持主卡观感) */
function leadCard(key: string) {
return cardStyle.styleOf(`tool.${key}`, 'workbench.lead')
}
</script>
<template>
<view>
<scroll-view
scroll-y
class="pane"
:show-scrollbar="false"
:refresher-enabled="true"
refresher-default-style="none"
:refresher-threshold="REFRESH_THRESHOLD"
:refresher-background="REFRESH_BG"
:refresher-triggered="refreshing"
@refresherpulling="onPulling"
@refresherrestore="onRestore"
@refresherrefresh="refresh"
>
<template #refresher>
<PaneRefresher :on="refreshing" :scale="bubbleScale" />
</template>
<!-- 渐变头区(橙色波浪) -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__meta">
@@ -86,8 +194,41 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 会员卡:首页会员 / 次数包转化入口(R1-1 上移到「我的工作台」之上) -->
<view class="tb-rise" style="animation-delay: 0ms">
<VipCard />
</view>
<!-- 便捷功能(笔记 / 年会查座):入口独立于工具库楼层(P1-2),来源 config/features.ts。
R5:整块上移到「我的工作台」之上(用户诉求)——进页面先看到两个功能入口。 -->
<view class="tb-card sec sec--gap tb-rise" style="animation-delay: 90ms">
<view class="sec__head">
<text class="tb-section-title">
便捷功能
</text>
</view>
<view class="feats">
<view
v-for="fe in FEATURE_ENTRIES"
:key="fe.key"
class="cttap press"
hover-class="press-active"
@tap="goFeature(fe.path)"
>
<CardTile
v-bind="featureCard(fe.key, 'workbench.features')"
:icon="fe.icon"
:title="fe.name"
:desc="fe.desc"
:illustration="fe.illustration"
size="md"
/>
</view>
</view>
</view>
<!-- 我的工作台 -->
<view class="tb-card sec tb-rise">
<view class="tb-card sec sec--gap tb-rise" style="animation-delay: 170ms">
<view class="sec__head">
<text class="tb-section-title">
我的工作台
@@ -98,7 +239,7 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
</view>
</view>
<view v-if="!props.ready" class="sec__hint">
<view v-if="!ready" class="sec__hint">
正在加载…
</view>
<EmptyState
@@ -110,59 +251,43 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
@action="goEdit"
/>
<template v-else>
<!-- 第 1 行:通栏主卡(最常用) -->
<!-- 通栏主卡(第 1 个固定工具) -->
<view
v-if="workbench[0]"
class="wb-lead tb-rise press"
class="cttap tb-rise press"
:style="{ animationDelay: '110ms' }"
hover-class="press-active"
@tap="openTool(workbench[0].path, workbench[0].key, workbench[0].locked)"
>
<Sticker type="star" :size="64" float :delay="400" class="wb-lead__sticker" />
<wd-icon
:name="workbench[0].icon"
size="60rpx"
:color="workbench[0].locked ? 'rgba(255,255,255,0.6)' : '#FFFFFF'"
class="wb-lead__icon"
<CardTile
v-bind="leadCard(workbench[0].key)"
:icon="workbench[0].icon"
:title="workbench[0].name"
:desc="workbench[0].desc"
:locked="workbench[0].locked"
illustration="rocket"
size="lg"
/>
<view class="wb-lead__body">
<text class="wb-lead__name">
{{ workbench[0].name }}
</text>
<text class="wb-lead__desc">
{{ workbench[0].desc }}
</text>
</view>
<view class="wb-lead__go">
<wd-icon name="arrow-right" size="28rpx" color="#FFFFFF" />
</view>
<view v-if="workbench[0].locked" class="tb-tile__corner">
未解锁
</view>
</view>
<!-- 第 2 行:两张副卡(底色与图标同源,保证成套) -->
<!-- 副卡(第 2–3 个固定工具) -->
<view v-if="workbench.length > 1" class="wb-sub-row">
<view
v-for="(t, i) in workbench.slice(1, 3)"
:key="t.key"
class="wb-sub tb-rise press"
:style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg, animationDelay: `${(i + 1) * 60}ms` }"
class="cttap tb-rise press"
:style="{ animationDelay: `${(i + 3) * 40}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<Sticker :type="SUB_STICKERS[i % SUB_STICKERS.length]" :size="56" float :delay="600 + i * 300" class="wb-sub__sticker" />
<wd-icon
:name="t.icon"
size="46rpx"
:color="t.locked ? '#BDA794' : toolPalette(t.key).fg"
class="wb-sub__icon"
<CardTile
v-bind="toolCard(t.key, 'workbench.sub')"
:icon="t.icon"
:title="t.name"
:locked="t.locked"
:illustration="SUB_STICKERS[i % SUB_STICKERS.length]"
size="md"
/>
<text class="wb-sub__name" :style="{ color: t.locked ? '#BDA794' : '#3D2A1E' }">
{{ t.name }}
</text>
</view>
</view>
@@ -171,33 +296,19 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
<view
v-for="(t, i) in workbench.slice(3)"
:key="t.key"
class="tb-tile tb-rise press"
:class="{ 'tb-tile--lock': t.locked }"
:style="{ animationDelay: `${Math.min(i + 3, 8) * 45}ms` }"
class="cttap tb-rise press"
:style="{ animationDelay: `${Math.min(i + 5, 9) * 35}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<view class="tb-tile__icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="46rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="tb-tile__name">
{{ t.name }}
</text>
<CardTile v-bind="toolCard(t.key, 'workbench.grid')" :icon="t.icon" :title="t.name" :locked="t.locked" :illustration="toolIllustration(t.key)" size="sm" />
</view>
</view>
</template>
</view>
<!-- 会员卡片:首页的会员 / 次数包转化入口 -->
<view style="margin-top: 28rpx">
<VipCard />
</view>
<!-- 发现工具 -->
<view v-if="hotTools.length" class="tb-card sec sec--gap tb-rise" style="animation-delay: 80ms">
<view v-if="hotTools.length" class="tb-card sec sec--gap tb-rise" style="animation-delay: 340ms">
<view class="sec__head">
<text class="tb-section-title">
发现工具
@@ -211,27 +322,27 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
<view
v-for="(t, i) in hotTools"
:key="t.tool_key"
class="hot tb-rise press"
:style="{ background: toolPalette(t.tool_key).bg, animationDelay: `${Math.min(i, 6) * 45 + 120}ms` }"
class="cttap tb-rise press"
:style="{ animationDelay: `${Math.min(i, 5) * 30 + 370}ms` }"
hover-class="press-active"
@tap="openTool(toolPath(t.tool_key), t.tool_key, t.locked)"
>
<view class="hot__badge">
<wd-icon :name="t.icon" size="30rpx" :color="t.locked ? '#BDA794' : toolPalette(t.tool_key).fg" />
</view>
<text class="hot__name">
{{ t.name }}
</text>
<text class="hot__desc">
{{ t.description }}
</text>
<CardTile
v-bind="toolCard(t.tool_key, 'workbench.hot')"
:icon="t.icon"
:title="t.name"
:desc="t.description"
:locked="t.locked"
:illustration="HOT_STICKERS[i % HOT_STICKERS.length]"
size="md"
/>
</view>
</view>
</view>
<view class="foot" />
</view>
</view>
</scroll-view>
</template>
<style scoped>
@@ -286,138 +397,50 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
padding: 50rpx 0;
}
/* 通栏主卡:橙渐变 */
.wb-lead {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 24rpx;
background: linear-gradient(135deg, #ff9f4d 0%, #ff6b35 55%, #fb4e14 100%);
border-radius: 30rpx;
padding: 30rpx 28rpx;
box-shadow: 0 14rpx 32rpx rgba(251, 78, 20, 0.32);
}
.wb-lead__sticker {
position: absolute;
right: 14rpx;
top: 10rpx;
opacity: 0.9;
}
.wb-lead__icon {
flex-shrink: 0;
filter: drop-shadow(0 6rpx 12rpx rgba(151, 60, 8, 0.25));
}
.wb-lead__body {
min-width: 0;
flex: 1;
}
.wb-lead__name {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #ffffff;
}
.wb-lead__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-lead__go {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.24);
display: flex;
align-items: center;
justify-content: center;
}
/* 通栏主卡 / 副卡 / 宫格 / 发现工具的视觉全部由 CardTile 组件库承担(P3),
此处仅保留网格容器与点击热区编排。
⚠️ 容器栅格属于**本文件**的职责:迁卡片视觉(P3)时不得连带删除下列网格规则,
否则容器退化为块级布局(每张卡占满整行),观感与上方两列副卡断裂。 */
/* 副卡:淡色轮换 + 贴纸角标 */
/* 副卡:两列网格(贴纸/渐变由 CardTile 决定) */
.wb-sub-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
.wb-sub {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
border-radius: 30rpx;
padding: 26rpx 24rpx 24rpx;
box-shadow: 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
}
.wb-sub__sticker {
position: absolute;
right: 10rpx;
top: 10rpx;
opacity: 0.85;
}
.wb-sub__icon {
filter: drop-shadow(0 4rpx 8rpx rgba(107, 47, 15, 0.12));
}
.wb-sub__name {
font-size: 28rpx;
font-weight: 700;
}
/* 其余固定工具宫格 */
/* 其余固定工具:四列宫格(回滚缺失的栅格,否则每张卡占满整行 → 观感断裂)。
R5:由 grid 改为 flex 换行 + flex-grow ——
· 满行(4 张)时每张恰好 1/4,与原来一致;
· 末行不足 4 张时,剩余卡片按**剩余位置等分**铺满(占 2 个位置就得到 2 个位置的宽度),
不再出现「一行只有两张、右侧大片空位」。
basis 里 `- 1rpx` 是防 rpx 取整把本该 4 张的一行挤成 3 张;多出的空间由 flex-grow 吸收。 */
.wb-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 18rpx;
margin-top: 22rpx;
}
.wb-grid .cttap {
flex: 1 1 calc((100% - 54rpx) / 4 - 1rpx);
min-width: 0;
}
/* 发现工具横滑卡 */
/* 便捷功能:两列 */
.feats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
/* 发现工具:两列 */
.hots {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.hot {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 30rpx;
padding: 24rpx;
box-shadow: 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
}
.hot__badge {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 12rpx rgba(107, 47, 15, 0.1);
}
.hot__name {
font-size: 28rpx;
font-weight: 800;
color: #3d2a1e;
}
.hot__desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #6b4f3a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 底部留白:避开悬浮 TabBar 与 Home Indicator */
.foot {

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
/**
* 优惠码输入组件(订单详情页)。
*
* 交互:输入码 → 「应用」emit apply(code);已应用态显示「清除」→ emit clear()。
* 与「选券」互斥由父组件保证(本组件只负责码)。
* 不用 input 事件逐字符打库,只在点「应用」时提交。
*/
import { ref, watch } from 'vue'
const props = defineProps<{
/** 已应用的优惠码(空串=未应用) */
applied?: string
disabled?: boolean
loading?: boolean
}>()
const emit = defineEmits<{ apply: [code: string], clear: [] }>()
const code = ref('')
watch(() => props.applied, (v) => {
if (!v)
code.value = ''
})
function onApply() {
const c = code.value.trim()
if (!c) {
uni.showToast({ title: '请输入优惠码', icon: 'none' })
return
}
emit('apply', c)
}
</script>
<template>
<view class="pci">
<view v-if="applied" class="pci__applied">
<view class="pci__tag">
<wd-icon name="check" size="22rpx" color="#0FA385" />
<text class="pci__tag-text">已应用优惠码 {{ applied }}</text>
</view>
<view class="pci__clear press" hover-class="press-active" @tap="emit('clear')">
清除
</view>
</view>
<view v-else class="pci__row">
<input
v-model="code"
class="pci__input"
placeholder="输入优惠码"
placeholder-style="color:#C9AE9C"
:disabled="disabled"
:maxlength="32"
confirm-type="done"
@confirm="onApply"
>
<view
class="pci__btn press"
:class="{ 'pci__btn--off': disabled || loading }"
hover-class="press-active"
@tap="disabled ? null : onApply()"
>
<text>{{ loading ? '应用中…' : '应用' }}</text>
</view>
</view>
</view>
</template>
<style scoped>
.pci {
width: 100%;
}
.pci__row {
display: flex;
align-items: center;
gap: 16rpx;
}
.pci__input {
flex: 1;
min-width: 0;
height: 80rpx;
box-sizing: border-box;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 27rpx;
color: #3d2a1e;
}
.pci__btn {
flex-shrink: 0;
padding: 0 36rpx;
height: 80rpx;
display: flex;
align-items: center;
border-radius: 20rpx;
font-size: 27rpx;
font-weight: 700;
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
}
.pci__btn--off {
opacity: 0.5;
}
.pci__applied {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
height: 80rpx;
padding: 0 24rpx;
border-radius: 20rpx;
background: #eafaf5;
border: 2rpx solid #b8ecdd;
}
.pci__tag {
display: flex;
align-items: center;
gap: 8rpx;
min-width: 0;
}
.pci__tag-text {
font-size: 26rpx;
color: #0b7a66;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pci__clear {
flex-shrink: 0;
font-size: 25rpx;
color: #9a7b65;
}
</style>

View File

@@ -0,0 +1,948 @@
<script setup lang="ts">
/**
* 「我的」页底部抽屉集合(绑定 H5 账号 / 意见反馈 / 卡片风格)——**页面根级**渲染。
*
* 为什么不在 PaneMine 内渲染(N5 真诊断结论):
* - 抽屉原先写在 PaneMine 的 `<scroll-view>` 子树里并靠 wot 的 `root-portal` 出栈,
* 但 `root-portal` 位于 WeChat 原生滚动容器 `scroll-view` 内部时无法真正「脱离页面」
* (证据:构建产物里抽屉节点始终在 pane 子树内),于是抽屉被困在 pane 的层叠/裁剪链里,
* 被 `position: fixed`、`z-index:1000` 的 TabBar 压住。
* - 现改为在 **`pages/index/index.vue` 的页面根级**(与 `<TabBar>` 同级)渲染:
* 祖先链 `page > .home` 均不建立层叠上下文,抽屉 `position: fixed; z-index: 2000`
* 与 TabBar `z-index: 1000` 同处页面根层叠上下文 → 2000 > 1000 必然盖住。
* 不再依赖任何第三方组件的出栈实现(不传 `root-portal`)。
*
* 对外通过 `defineExpose({ openFeedback, openBind, openCardStyle })` 暴露打开能力,由 index.vue 编排。
*/
import type { CardSection, CardStyle, CardTone, CardVariant, MemberSource } from '@/config/card-styles'
import type { CardTemplate, TemplateThumbLayout } from '@/config/card-templates'
import { computed, ref, watch } from 'vue'
import { apiBindAccount, apiFeedbackSubmit } from '@/api/user'
import Confetti from '@/components/Confetti.vue'
import { CARD_SECTIONS, CARD_TONES, CARD_VARIANTS, GLOBAL_DEFAULT_STYLE } from '@/config/card-styles'
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 { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { Z } from '@/utils/z-index'
const userStore = useUserStore()
const toolsStore = useToolsStore()
const cardStyle = useCardStyleStore()
const feedbackVisible = ref(false)
const feedbackContent = ref('')
const feedbackContact = ref('')
const submitting = ref(false)
const bindVisible = ref(false)
const bindUsername = ref('')
const bindPassword = ref('')
const binding = ref(false)
const cardStyleVisible = ref(false)
/** 彩带触发计数(提交成功庆祝) */
const burst = ref(0)
function openFeedback() {
feedbackVisible.value = true
}
function openBind() {
bindVisible.value = true
}
function openCardStyle() {
cardStyleVisible.value = true
}
/** 一级 tab:当前选中区块(默认第一个区块) */
const activeSection = ref(CARD_SECTIONS[0]?.key || '')
/** 二级 tab:当前选中目标 key(由下方 watch 在区块切换时自动重置为该区块首个目标) */
const activeTgt = ref('')
/** 当前区块对象(查不到时回落到第一个,避免空指针) */
const activeSec = computed(() => CARD_SECTIONS.find(s => s.key === activeSection.value) || CARD_SECTIONS[0])
/** 当前区块下的全部目标(「整个区块」+ 静态成员 + 运行时成员,响应式) */
const activeTargets = computed(() => sectionTargets(activeSec.value))
/** 当前选中目标对象(无匹配回落到首个;区块 targets 为空时为 null,模板据此安全不渲染内容区) */
const activeTarget = computed(() => activeTargets.value.find(t => t.key === activeTgt.value) || activeTargets.value[0] || null)
/** 内容区渲染用的「0 或 1 元素」列表:保留既有 `tgt.*` 绑定写法不变,仅收敛为单目标 */
const activeTargetList = computed(() => (activeTarget.value ? [activeTarget.value] : []))
// 一级 tab 切换 → 二级选中自动重置为该区块第一个 target;列表为空时置空串(不报错、不白屏)
watch(activeTargets, (list) => {
if (!list.some(t => t.key === activeTgt.value))
activeTgt.value = list[0]?.key || ''
}, { immediate: true })
/** 抽屉内一个可配置目标(带其所属区块,用于「区块 + 入口」逐入口存储寻址) */
interface DrawerTarget { key: string, label: string, section: string }
/**
* 运行时成员:按 `memberSource` 去 store 取**当前真实入口**(响应式 —— 用户在别处增删固定工具后重开抽屉即见)。
* 全部走 `tool.<tool_key>` / `feature.<key>` 命名;`locked` 的工具仍列出(它也有卡片,也要能配风格)。
*/
function runtimeMembers(src: MemberSource, secKey: string): DrawerTarget[] {
if (src === 'features')
return FEATURE_ENTRIES.map(fe => ({ key: `feature.${fe.key}`, label: fe.name, section: secKey }))
if (src === 'workbench')
return toolsStore.workbenchTools().map(t => ({ key: `tool.${t.key}`, label: t.locked ? `${t.name}(未解锁)` : t.name, section: secKey }))
if (src === 'hot')
return toolsStore.tools
.filter(t => t.is_hot && !toolsStore.workbenchKeys.includes(t.tool_key))
.slice(0, 6)
.map(t => ({ key: `tool.${t.tool_key}`, label: t.locked ? `${t.name}(未解锁)` : t.name, section: secKey }))
// floors:各模块楼层下的工具,label 用「模块名 · 工具名」
const out: DrawerTarget[] = []
for (const m of toolsStore.modules) {
for (const d of TOOL_REGISTRY.filter(x => x.module === m.module_key)) {
const locked = toolsStore.tools.find(x => x.tool_key === d.key)?.locked ?? true
out.push({ key: `tool.${d.key}`, label: `${m.name} · ${d.name}${locked ? '(未解锁)' : ''}`, section: secKey })
}
}
return out
}
/** 该区块下可配置的入口:区块自身 + 静态 members + 运行时成员(**合并去重**;运行时为空则不报错、不产生空列表) */
function sectionTargets(sec: CardSection): DrawerTarget[] {
const list: DrawerTarget[] = [{ key: `section.${sec.key}`, label: '整个区块', section: sec.key }]
const seen = new Set<string>([`section.${sec.key}`])
for (const m of sec.members) {
if (!seen.has(m)) {
seen.add(m)
list.push({ key: m, label: m, section: sec.key })
}
}
if (sec.memberSource) {
for (const t of runtimeMembers(sec.memberSource, sec.key)) {
if (!seen.has(t.key)) {
seen.add(t.key)
list.push(t)
}
}
}
return list
}
/** 无逐入口清单的区块,给一行说明,避免用户以为漏了 */
function sectionNote(sec: CardSection): string {
if (sec.memberSource || sec.members.length)
return ''
return '该区块为动态列表,按「整个区块」统一设置'
}
/** 行内提示:区块自带的 `hint` 优先,否则回落到通用说明(无内容则返回空串不渲染) */
function hintOf(sec: CardSection): string {
return sec.hint || sectionNote(sec)
}
// ---------------------------------------------------------------------------
// R5:改为「草稿 + 保存才生效」
// 原实现是「选中即写 store 即持久化」——用户在真机上横滑 chip 时极易误触,
// 且全程没有「确认」这一步,不知道自己到底存没存。现改为:
// 所有点选先写**本地草稿** → 抽屉内实时预览(草稿按「区块级→成员级」合并,与 store 同序)
// → 只有点「保存」才落库;未保存就关抽屉 = 整份草稿丢弃并提示。
// ---------------------------------------------------------------------------
/** 草稿:存储键 → 待保存补丁(键必须与 store 的 storageKey 完全一致,否则保存会写到另一个键上) */
const draft = ref<Record<string, Partial<CardStyle>>>({})
/** 「恢复默认」是否已排进草稿(保存时先清空全部偏好再落草稿,保证「保存前什么都不生效」) */
const resetPending = ref(false)
/** 目标 → 草稿键:区块级即自身;成员级为 `<区块>::<目标>`(复刻 store.storageKey 的约定) */
function draftKeyOf(tgt: DrawerTarget): string {
if (tgt.key.startsWith('section.'))
return tgt.key
return tgt.section ? `${tgt.section}::${tgt.key}` : tgt.key
}
/** 该目标生效中的草稿补丁(区块级草稿为基线,成员级草稿覆盖之 —— 与 store.explicitOf 同序) */
function draftOf(tgt: DrawerTarget): Partial<CardStyle> {
const secDraft = tgt.section ? draft.value[`section.${tgt.section}`] : undefined
const memDraft = draft.value[draftKeyOf(tgt)]
return { ...secDraft, ...memDraft }
}
/** 是否存在未保存的改动 */
const dirty = computed(() => resetPending.value || Object.keys(draft.value).length > 0)
/** 「恢复默认」后的预览基线:区块默认(并按其版式白名单钳制),与 store 的回落顺序一致 */
function sectionDefaultOf(secKey: string): CardStyle {
const sec = CARD_SECTIONS.find(s => s.key === secKey)
const d = sec?.default || GLOBAL_DEFAULT_STYLE
const allow = sec?.templates
const template = allow && !allow.includes(d.template) ? (allow[0] || d.template) : d.template
return { template, variant: d.variant, tone: d.tone }
}
/** 读某目标当前样式:草稿优先,无草稿则读 store 的已保存值(三级解析:显式 → 区块 → 全局) */
function styleOfKey(tgt: DrawerTarget): CardStyle {
const base = resetPending.value ? sectionDefaultOf(tgt.section) : cardStyle.styleOf(tgt.key, tgt.section)
const d = draftOf(tgt)
return {
template: d.template || base.template,
variant: d.variant || base.variant,
tone: d.tone || base.tone,
}
}
/** 写入草稿(**不落库**;点「保存」才生效) */
function applyStyle(tgt: DrawerTarget, patch: Partial<CardStyle>) {
const k = draftKeyOf(tgt)
draft.value = { ...draft.value, [k]: { ...draft.value[k], ...patch } }
}
/** 角标小样:随该目标当前色系派生(与 CardTile 的 --ct-badge-* 同源) */
function badgePreview(tgt: DrawerTarget) {
const t = CARD_TONES.find(x => x.id === styleOfKey(tgt).tone) || CARD_TONES[0]
return { background: t.badgeBg, color: t.badgeFg }
}
/** 该目标是否「继承自区块」:草稿层也要算进去(否则刚改了区块、成员上还显示旧提示) */
function inheritedFromBlock(tgt: DrawerTarget): boolean {
if (tgt.key.startsWith('section.'))
return false
const memDraft = draft.value[draftKeyOf(tgt)]
if (memDraft && Object.keys(memDraft).length)
return false
const secDraft = tgt.section ? draft.value[`section.${tgt.section}`] : undefined
if (secDraft && Object.keys(secDraft).length)
return true
if (resetPending.value)
return false
return cardStyle.isInherited(tgt.key, tgt.section)
}
/** 「恢复默认」:只排进草稿,点「保存」才真正清空已存偏好 */
function resetCardStyle() {
resetPending.value = true
draft.value = {}
uni.showToast({ title: '保存后恢复默认', icon: 'none' })
}
/** 保存:先落「恢复默认」(若有),再逐条落草稿,最后收起抽屉 */
function saveCardStyle() {
if (!dirty.value) {
uni.showToast({ title: '还没有改动', icon: 'none' })
return
}
if (resetPending.value)
cardStyle.reset()
for (const [k, patch] of Object.entries(draft.value))
cardStyle.setStyle(k, patch)
resetPending.value = false
draft.value = {}
uni.showToast({ title: '已保存', icon: 'success' })
cardStyleVisible.value = false
}
/** 抽屉开合:打开时清空草稿(每次都从已保存状态重新开始);关闭时丢弃未保存草稿并提示 */
watch(cardStyleVisible, (visible) => {
if (visible) {
draft.value = {}
resetPending.value = false
return
}
if (dirty.value)
uni.showToast({ title: '未保存的改动已放弃', icon: 'none' })
draft.value = {}
resetPending.value = false
})
/** 当前区块允许的版式:容器 / 窄列区块受白名单约束(见 card-styles.ts 的 CardSection.templates)——
否则把楼层配成非容器版式会让里面的工具小卡整段消失,窄列配成通栏主卡会把文案挤成逐字换行 */
const templateChoices = computed(() => {
const allow = activeSec.value?.templates
return allow ? CARD_TEMPLATES.filter(t => allow.includes(t.id)) : CARD_TEMPLATES
})
/** 版式缩略:内容排布(缺省 'top',与 CardTile 的 iconTop 骨架一致) */
function thumbLayout(tpl: CardTemplate): TemplateThumbLayout {
return tpl.preview.thumb?.layout || 'top'
}
/**
* 版式缩略内部小块的填充色:
* - duo 强渐变底用白字 → 缩略块取白;
* - 其余取该版式前景色;`soft` 为更淡的第二层(横条/子块)。
* opacity 直接放在元素上(MP 支持),避免为每个色值算 rgba。
*/
function thumbInk(tpl: CardTemplate, soft = false) {
// 仅 duo 是强渐变底(用白字);tile / 其余版式走默认(非白字,取该版式前景色)
const isDuo = thumbLayout(tpl) === 'duo'
return {
background: isDuo ? '#FFFFFF' : tpl.preview.fg,
opacity: soft ? 0.42 : 0.92,
}
}
/** 皮肤小样:按「皮肤 + 色系」画小色块,直观预览 */
function skinPreview(v: CardVariant, toneId: string) {
const t = CARD_TONES.find(x => x.id === toneId) || CARD_TONES[0]
const style: Record<string, string> = {}
if (v.id === 'porcelain')
style.background = `linear-gradient(135deg, ${t.from}, ${t.to})`
else if (v.id === 'tint')
style.background = t.tintBg
else if (v.id === 'solid')
style.background = `linear-gradient(135deg, ${t.gradFrom}, ${t.gradTo})`
else
style.background = v.preview.bg
if (v.preview.border)
style.border = `1rpx solid ${v.preview.border}`
return style
}
/** 色系小样 */
function tonePreview(t: CardTone) {
return { background: `linear-gradient(135deg, ${t.from}, ${t.to})` }
}
async function submitFeedback() {
if (!feedbackContent.value.trim()) {
uni.showToast({ title: '请填写反馈内容', icon: 'none' })
return
}
submitting.value = true
try {
await apiFeedbackSubmit(feedbackContent.value.trim(), feedbackContact.value.trim())
uni.showToast({ title: '感谢反馈', icon: 'success' })
feedbackVisible.value = false
feedbackContent.value = ''
feedbackContact.value = ''
burst.value++
}
finally {
submitting.value = false
}
}
async function submitBind() {
const name = bindUsername.value.trim()
const pass = bindPassword.value
if (!name || !pass) {
uni.showToast({ title: '请填写 H5 账号的用户名和密码', icon: 'none' })
return
}
binding.value = true
try {
await apiBindAccount(name, pass)
uni.showToast({ title: '绑定成功,数据已合并', icon: 'success' })
bindVisible.value = false
bindUsername.value = ''
bindPassword.value = ''
burst.value++
// 合并后两端数据打通:刷新登录态与工具表
await Promise.all([userStore.ensureLogin(), toolsStore.fetchAll()])
}
finally {
binding.value = false
}
}
defineExpose({ openFeedback, openBind, openCardStyle })
</script>
<template>
<view class="mine-drawers">
<!-- 绑定 H5 账号弹层:页面根级 + sheet 层级,完整盖过 TabBar -->
<wd-popup
v-model="bindVisible"
position="bottom"
:z-index="Z.sheet"
custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;"
>
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
绑定 H5 账号
</text>
<text class="fb__sub">
合并后小程序与 H5 共用同一份数据,H5 端可用该账号密码登录
</text>
<view class="fb__box">
<wd-input v-model="bindUsername" placeholder="H5 账号的用户名" no-border custom-class="fb__input" />
</view>
<view class="fb__box">
<wd-input v-model="bindPassword" password placeholder="H5 账号的密码" no-border custom-class="fb__input" />
</view>
<wd-button
type="primary"
block
:loading="binding"
custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="submitBind"
>
绑定并合并数据
</wd-button>
</view>
</wd-popup>
<!-- 意见反馈弹层:同上,页面根级 + sheet 层级 -->
<wd-popup
v-model="feedbackVisible"
position="bottom"
:z-index="Z.sheet"
custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;"
>
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
意见反馈
</text>
<text class="fb__sub">
说说你想用的工具,或遇到的问题
</text>
<view class="fb__box">
<wd-textarea
v-model="feedbackContent"
placeholder="写下你的想法…"
:maxlength="500"
show-word-limit
custom-class="fb__textarea"
/>
</view>
<view class="fb__box">
<wd-input v-model="feedbackContact" placeholder="联系方式(选填)" no-border custom-class="fb__input" />
</view>
<wd-button type="primary" block :loading="submitting" custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);" @click="submitFeedback">
提交反馈
</wd-button>
</view>
</wd-popup>
<!-- 卡片风格(P2):页面根级 + sheet 层级 -->
<wd-popup
v-model="cardStyleVisible"
position="bottom"
:z-index="Z.sheet"
custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;"
>
<view class="fb cs">
<view class="fb__grip" />
<view class="cs__head">
<text class="fb__title">
卡片风格
</text>
<view class="cs__reset press" hover-class="press-active" @tap="resetCardStyle">
恢复默认
</view>
</view>
<text class="fb__sub">
按区块设置每张卡片的版式 / 皮肤 / 色系,选完点「保存」生效
</text>
<!-- 一级 tab:区块(横滑,选中即切换当前区块) -->
<scroll-view scroll-x class="cs__tabs1" :show-scrollbar="false">
<view class="cs__tabs1-inner">
<view
v-for="sec in CARD_SECTIONS"
:key="sec.key"
class="taby press"
:class="{ 'taby--on': activeSection === sec.key }"
hover-class="press-active"
@tap="activeSection = sec.key"
>
{{ sec.label }}
</view>
</view>
</scroll-view>
<!-- 二级 tab:当前区块内的配置目标(横滑,含「整个区块」与各工具/入口) -->
<scroll-view scroll-x class="cs__tabs2" :show-scrollbar="false">
<view class="cs__tabs2-inner">
<view
v-for="tgt in activeTargets"
:key="tgt.key"
class="tabx press"
:class="{ 'tabx--on': activeTgt === tgt.key }"
hover-class="press-active"
@tap="activeTgt = tgt.key"
>
{{ tgt.label }}
</view>
</view>
</scroll-view>
<!-- 内容区:只渲染当前选中 target 的版式 / 皮肤 / 色系 -->
<scroll-view scroll-y class="cs__list" :show-scrollbar="false">
<view v-for="tgt in activeTargetList" :key="tgt.key" class="cs__target">
<view class="cs__target-head">
<view class="cs__target-name">
<text class="cs__target-label">
{{ tgt.label }}
</text>
<text v-if="inheritedFromBlock(tgt)" class="cs__inherit">
继承自区块
</text>
</view>
<text class="cs__badge" :style="badgePreview(tgt)">
推荐
</text>
</view>
<text class="cs__rowlabel">
版式
</text>
<scroll-view scroll-x enable-flex class="cs__chips" :show-scrollbar="false">
<view class="cs__chips-inner">
<view
v-for="tpl in templateChoices"
:key="tpl.id"
class="chipx press"
:class="{ 'chipx--on': styleOfKey(tgt).template === tpl.id }"
hover-class="press-active"
@tap="applyStyle(tgt, { template: tpl.id })"
>
<!-- 迷你卡片缩略:形状走 wxss(按 id 精确复刻 CardTile 圆角),排布走 thumbLayout -->
<view
class="chipx__thumb"
:class="[`chipx__thumb--sh-${tpl.id}`, `chipx__thumb--ly-${thumbLayout(tpl)}`]"
:style="{ background: tpl.preview.bg }"
>
<template v-if="thumbLayout(tpl) === 'top'">
<view class="cs__mi cs__mi--sq" :style="thumbInk(tpl)" />
<view class="cs__mi cs__mi--bar" :style="thumbInk(tpl, true)" />
</template>
<template v-else-if="thumbLayout(tpl) === 'left'">
<view class="cs__mi cs__mi--sq cs__mi--sqsm" :style="thumbInk(tpl)" />
<view class="cs__col">
<view class="cs__mi cs__mi--bar" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--bar cs__mi--barsm" :style="thumbInk(tpl, true)" />
</view>
</template>
<template v-else-if="thumbLayout(tpl) === 'lead'">
<view class="cs__col">
<view class="cs__mi cs__mi--bar" :style="thumbInk(tpl)" />
<view class="cs__mi cs__mi--bar cs__mi--barsm" :style="thumbInk(tpl, true)" />
</view>
<view class="cs__mi cs__mi--big" :style="thumbInk(tpl, true)" />
</template>
<template v-else-if="thumbLayout(tpl) === 'duo'">
<view class="cs__mi cs__mi--bar cs__mi--barsm" :style="thumbInk(tpl)" />
</template>
<!-- tile:左上小 pill 角标 + 两条 bar + 右下小方(对齐参考图 B 瓷片) -->
<template v-else-if="thumbLayout(tpl) === 'tile'">
<view class="cs__mi cs__mi--pill" :style="thumbInk(tpl)" />
<view class="cs__mi cs__mi--bar cs__mi--barsm" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--bar cs__mi--barsm" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--sqbr" :style="thumbInk(tpl, true)" />
</template>
<template v-else>
<view class="cs__mi cs__mi--bar cs__mi--width" :style="thumbInk(tpl)" />
<view class="cs__grid">
<view class="cs__mi cs__mi--cell" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--cell" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--cell" :style="thumbInk(tpl, true)" />
<view class="cs__mi cs__mi--cell" :style="thumbInk(tpl, true)" />
</view>
</template>
</view>
<text class="chipx__text">
{{ tpl.label }}
</text>
</view>
</view>
</scroll-view>
<text class="cs__rowlabel">
皮肤
</text>
<scroll-view scroll-x class="cs__chips" :show-scrollbar="false">
<view class="cs__chips-inner">
<view
v-for="v in CARD_VARIANTS"
:key="v.id"
class="chipx press"
:class="{ 'chipx--on': styleOfKey(tgt).variant === v.id }"
hover-class="press-active"
@tap="applyStyle(tgt, { variant: v.id })"
>
<view class="chipx__dot" :style="skinPreview(v, styleOfKey(tgt).tone)" />
<text class="chipx__text">
{{ v.label }}
</text>
</view>
</view>
</scroll-view>
<text class="cs__rowlabel">
色系
</text>
<scroll-view scroll-x class="cs__chips" :show-scrollbar="false">
<view class="cs__chips-inner">
<view
v-for="tn in CARD_TONES"
:key="tn.id"
class="chipx press"
:class="{ 'chipx--on': styleOfKey(tgt).tone === tn.id }"
hover-class="press-active"
@tap="applyStyle(tgt, { tone: tn.id })"
>
<view class="chipx__dot" :style="tonePreview(tn)" />
<text class="chipx__text">
{{ tn.label }}
</text>
</view>
</view>
</scroll-view>
</view>
<view v-if="activeSec && hintOf(activeSec)" class="cs__hint">
{{ hintOf(activeSec) }}
</view>
</scroll-view>
<!-- 底部操作条:改动先存草稿、点「保存」才生效(未保存时给出明确状态,避免「配了不知道存没存」) -->
<view class="cs__foot">
<text class="cs__dirty" :class="{ 'cs__dirty--on': dirty }">
{{ dirty ? '有未保存的改动' : '已是最新' }}
</text>
<wd-button
type="primary"
block
:disabled="!dirty"
custom-style="border-radius: 999rpx; height: 84rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="saveCardStyle"
>
保存
</wd-button>
</view>
</view>
</wd-popup>
<!-- 彩带庆祝 -->
<Confetti :burst="burst" />
</view>
</template>
<style scoped>
/* 层容器不参与布局(抽屉自身 position: fixed 定位于视口) */
.mine-drawers {
position: static;
}
/* 反馈/绑定弹层内容 */
.fb {
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 20rpx 32rpx calc(36rpx + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 20rpx;
}
.fb__grip {
align-self: center;
width: 72rpx;
height: 8rpx;
border-radius: 999rpx;
background: #ffe3d3;
margin-bottom: 4rpx;
}
.fb__title {
font-size: 34rpx;
font-weight: 800;
color: #3d2a1e;
}
.fb__sub {
margin-top: -12rpx;
font-size: 24rpx;
color: #9a7b65;
}
.fb__box {
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 6rpx 20rpx;
}
/* 卡片风格设置(P3:区块 tab + 目标 tab 两级横滑) */
.cs {
gap: 14rpx;
}
.cs__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.cs__reset {
font-size: 25rpx;
color: #9a7b65;
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 1rpx solid #ffe3d3;
}
.cs__list {
/* 底部新增操作条(保存)后内容区相应收窄,避免抽屉整体顶出屏幕 */
max-height: 46vh;
margin-top: 4rpx;
}
/* 底部操作条:未保存状态文案 + 保存按钮(草稿模型:不点保存不落库) */
.cs__foot {
display: flex;
flex-direction: column;
gap: 10rpx;
padding-top: 14rpx;
margin-top: 6rpx;
border-top: 1rpx solid #f4e8de;
}
.cs__dirty {
align-self: center;
font-size: 22rpx;
color: #b3907c;
}
.cs__dirty--on {
color: #e8531a;
font-weight: 700;
}
/* 一级 tab:区块(横滑;选中用品牌橙实心 pill,与二级 tab 明显区分) */
.cs__tabs1 {
width: 100%;
white-space: nowrap;
}
.cs__tabs1-inner {
display: inline-flex;
gap: 12rpx;
/* 右侧留白:让最后一项出现可见裁切 → 暗示「还能往左滑」 */
padding: 2rpx 24rpx 2rpx 2rpx;
}
.taby {
display: inline-flex;
align-items: center;
/* 横滑容器内不被压扁 */
flex-shrink: 0;
padding: 12rpx 24rpx;
border-radius: 999rpx;
font-size: 25rpx;
font-weight: 700;
color: #9a7b65;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
}
.taby--on {
color: #ffffff;
background: linear-gradient(135deg, #ff9a4d, #fb4e14);
border-color: #fb4e14;
box-shadow: 0 6rpx 16rpx rgba(251, 78, 20, 0.28);
}
/* 二级 tab:当前区块内的目标(横滑;选中用浅金色描边 pill,层级低于一级) */
.cs__tabs2 {
width: 100%;
white-space: nowrap;
}
.cs__tabs2-inner {
display: inline-flex;
gap: 10rpx;
padding: 2rpx 24rpx 2rpx 2rpx;
}
.tabx {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 10rpx 22rpx;
border-radius: 999rpx;
font-size: 23rpx;
color: #6b4f3a;
background: #fffdfb;
border: 2rpx solid #f4e8de;
}
.tabx--on {
color: #b5541a;
background: #fff4d6;
border-color: #f5c56b;
box-shadow: 0 4rpx 12rpx rgba(224, 166, 63, 0.22);
}
.cs__target {
background: #fffdfb;
border: 1rpx solid #f4e8de;
border-radius: 20rpx;
padding: 16rpx 16rpx 14rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.cs__target-label {
font-size: 24rpx;
font-weight: 700;
color: #3d2a1e;
}
.cs__target-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.cs__target-name {
display: flex;
align-items: center;
gap: 8rpx;
min-width: 0;
}
/* 「继承自区块」提示:成员行未单独设过、但区块级已设时显示 */
.cs__inherit {
flex-shrink: 0;
font-size: 18rpx;
color: #b3907c;
background: #fff4e6;
border: 1rpx solid #ffe0c2;
border-radius: 999rpx;
padding: 2rpx 12rpx;
}
/* 角标小样(随当前色系派生,直观展示对比度) */
.cs__badge {
flex-shrink: 0;
font-size: 18rpx;
font-weight: 700;
padding: 2rpx 14rpx;
border-radius: 999rpx;
box-shadow: 0 2rpx 6rpx rgba(107, 47, 15, 0.2);
}
/* 无逐入口清单 / 区块自带说明的行内提示 */
.cs__hint {
font-size: 21rpx;
color: #b3907c;
padding: 2rpx 4rpx;
}
.cs__rowlabel {
font-size: 21rpx;
color: #b3907c;
margin-top: 2rpx;
}
.cs__chips {
width: 100%;
white-space: nowrap;
}
.cs__chips-inner {
display: inline-flex;
gap: 12rpx;
/* 右侧留白:让最后一项右侧出现可见裁切 → 暗示「还能往左滑」 */
padding: 2rpx 24rpx 4rpx 2rpx;
}
.chipx {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
/* 横滑容器内不被压扁(否则 9 个版式会被挤成一条) */
flex-shrink: 0;
}
.chipx--on {
background: #fff4d6;
border-color: #f5c56b;
box-shadow: 0 6rpx 16rpx rgba(224, 166, 63, 0.22);
}
.chipx__dot {
width: 28rpx;
height: 28rpx;
border-radius: 10rpx;
box-sizing: border-box;
box-shadow: 0 2rpx 6rpx rgba(107, 47, 15, 0.15);
}
/* ===== 版式缩略:迷你卡片(形状按 id 复刻 CardTile 真实圆角;内容排布按 thumb.layout)=====
P5 用户反馈③「版式要能大概看出来长啥样」:原 44×34rpx(≈22×17px)下形状/排布不可辨,
故整体等比放大 2 倍(96×74rpx ≈ 48×37px),并加细描边强化轮廓(拱形/叶形/缺角的差异才看得出来)。 */
.chipx__thumb {
position: relative;
width: 96rpx;
height: 74rpx;
box-sizing: border-box;
flex-shrink: 0;
display: flex;
padding: 10rpx;
border-radius: 24rpx;
/* 轮廓描边:底色多为浅色系,无描边时形状差异会被吃掉 */
box-shadow:
inset 0 0 0 2rpx rgba(107, 47, 15, 0.08),
0 4rpx 10rpx rgba(107, 47, 15, 0.1);
/* 不用 overflow:hidden —— 层叠卡的偏移实心投影需要溢出可见 */
overflow: visible;
}
/* 形状(按版式 id)—— rpx 为 CardTile 真实圆角的等比缩小 */
.chipx__thumb--sh-iconTop { border-radius: 24rpx; }
.chipx__thumb--sh-iconLeft { border-radius: 24rpx; }
.chipx__thumb--sh-lead { border-radius: 24rpx; }
.chipx__thumb--sh-duo { border-radius: 24rpx; }
.chipx__thumb--sh-group { border-radius: 24rpx; }
.chipx__thumb--sh-notch { border-radius: 0 28rpx 28rpx 28rpx; }
.chipx__thumb--sh-arch { border-radius: 44rpx 44rpx 24rpx 24rpx / 64rpx 64rpx 24rpx 24rpx; }
.chipx__thumb--sh-leaf { border-radius: 32rpx 6rpx 32rpx 6rpx; }
.chipx__thumb--sh-stack {
border-radius: 24rpx;
box-shadow:
inset 0 0 0 2rpx rgba(107, 47, 15, 0.08),
0 8rpx 0 -2rpx rgba(176, 148, 255, 0.62),
0 16rpx 0 -6rpx rgba(210, 192, 255, 0.42);
}
/* 瓷片(tile):常规大圆角(与 CardTile 的 .ct--tile 骨架一致) */
.chipx__thumb--sh-tile { border-radius: 24rpx; }
/* 内容排布(按 thumb.layout) */
.chipx__thumb--ly-top {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.chipx__thumb--ly-left {
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.chipx__thumb--ly-lead {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.chipx__thumb--ly-duo {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.chipx__thumb--ly-group {
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 6rpx;
}
/* tile:纵向左对齐(pill → 两条 bar → 右下小方) */
.chipx__thumb--ly-tile {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 5rpx;
}
/* 缩略内部小块(填充色由 thumbInk 内联注入,尺寸/圆角在此) */
.cs__mi {
box-sizing: border-box;
border-radius: 4rpx;
}
.cs__mi--sq { width: 28rpx; height: 28rpx; border-radius: 8rpx; }
.cs__mi--sqsm { width: 20rpx; height: 20rpx; }
.cs__mi--bar { width: 40rpx; height: 8rpx; }
.cs__mi--barsm { width: 24rpx; }
.cs__mi--width { width: 52rpx; }
.cs__mi--big { width: 20rpx; height: 36rpx; border-radius: 6rpx; }
.cs__mi--cell { width: 26rpx; height: 14rpx; }
/* tile 缩略内部块:左上小 pill / 右下小方 */
.cs__mi--pill { width: 22rpx; height: 8rpx; border-radius: 999rpx; }
.cs__mi--sqbr { width: 20rpx; height: 20rpx; border-radius: 6rpx; align-self: flex-end; margin-top: auto; }
.cs__col {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.cs__grid {
display: flex;
flex-wrap: wrap;
gap: 6rpx;
width: 100%;
}
.chipx__text {
font-size: 24rpx;
color: #3d2a1e;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
/**
* 工具页「未选择」空态(T02 脚手架)
*
* 统一的选图入口占位:图标 + 主文案 + 可选提示。点击整块触发 `pick` 事件,
* 由页面决定是「选图」还是「其它动作」。
*/
withDefaults(defineProps<{
/** 主文案 */
text?: string
/** 次要提示(可选) */
hint?: string
/** wot 图标名 */
icon?: string
}>(), {
text: '点击选择图片',
hint: '',
icon: 'add',
})
defineEmits<{ (e: 'pick'): void }>()
</script>
<template>
<view class="tb-idle press tb-elev-1" hover-class="press-active" @tap="$emit('pick')">
<view class="tb-idle__ico tb-ico-shadow">
<wd-icon :name="icon" size="52rpx" color="#ff6b35" />
</view>
<text class="tb-idle__text">
{{ text }}
</text>
<text v-if="hint" class="tb-idle__hint">
{{ hint }}
</text>
</view>
</template>
<style scoped>
.tb-idle {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 90rpx 40rpx;
}
.tb-idle__ico {
width: 108rpx;
height: 108rpx;
border-radius: 28rpx;
background: linear-gradient(160deg, #fff4ec, #ffe3d3);
display: flex;
align-items: center;
justify-content: center;
}
.tb-idle__text {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.tb-idle__hint {
font-size: 22rpx;
color: #9a7b65;
text-align: center;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* 图片工具页统一外壳(T02 脚手架)
*
* 统一「渐变头区 + 上浮内容区」的布局骨架,供各工具页复用,避免逐页重复 nav/hero 样式:
* - 头区:返回按钮 + 标题 + 副标题 + `hero-extra` 具名插槽(放额度/说明等右上角附加物);
* - 内容区:默认插槽(上浮压住头区,复用全局 `.tb-body`);
* - `needCanvas`:为真时在页面内挂载图片引擎工作画布 `<canvas id="imgCanvas">`(仅 MP)。
*
* ⚠️ 画布 id 固定为 `imgCanvas`,与 `useImageTool` 的 `bindImageCanvas('#imgCanvas')` 对应;
* 与「年会查座」等其它画布任务使用**不同 id**,互不冲突。
*/
import { heroPadTop } from '@/utils/safearea'
withDefaults(defineProps<{
/** 头区主标题 */
title: string
/** 头区副标题(可选) */
subtitle?: string
/** 是否挂载图片引擎工作画布(MP 端需要 canvas 做绘图的页面传 true) */
needCanvas?: boolean
}>(), {
subtitle: '',
needCanvas: false,
})
const padTop = heroPadTop()
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="tb-tool">
<view class="tb-hero tb-tool__hero" :style="{ paddingTop: padTop }">
<view class="tb-tool__nav">
<view class="tb-tool__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
</view>
<view class="tb-tool__titles">
<text class="tb-tool__title">
{{ title }}
</text>
<text v-if="subtitle" class="tb-tool__subtitle">
{{ subtitle }}
</text>
</view>
<view class="tb-tool__extra">
<slot name="hero-extra" />
</view>
</view>
</view>
<view class="tb-body tb-tool__body">
<slot />
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布:onReady 时由 useImageTool → bindImageCanvas('#imgCanvas') 绑定 -->
<canvas v-if="needCanvas" id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.tb-tool {
min-height: 100vh;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.tb-tool__hero {
padding-left: 32rpx;
padding-right: 32rpx;
}
.tb-tool__nav {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 20rpx;
}
.tb-tool__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;
}
.tb-tool__titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.tb-tool__title {
font-size: 36rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.01em;
}
.tb-tool__subtitle {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.tb-tool__extra {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12rpx;
}
.tb-tool__body {
display: flex;
flex-direction: column;
gap: 28rpx;
padding-top: 4rpx;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
/**
* 工具页「处理中」遮罩(T02 脚手架)
*
* 全局固定遮罩 + 卡片:转圈 + 文案 + 可选进度条。
* `progress` 预留:默认 -1 = 不确定进度(只转圈);0~100 时展示进度条。
*/
import { Z } from '@/utils/z-index'
withDefaults(defineProps<{
/** 是否显示 */
show?: boolean
/** 文案 */
text?: string
/** 进度 0~100;-1(默认)表示不确定进度 */
progress?: number
}>(), {
show: false,
text: '处理中…',
progress: -1,
})
</script>
<template>
<view v-if="show" class="tb-run" :style="{ zIndex: Z.decor }">
<view class="tb-run__box tb-elev-3">
<view class="tb-run__ring" />
<text class="tb-run__text">
{{ text }}
</text>
<view v-if="progress >= 0" class="tb-run__bar">
<view
class="tb-run__fill"
:style="{ width: `${Math.max(0, Math.min(100, progress))}%` }"
/>
</view>
</view>
</view>
</template>
<style scoped>
.tb-run {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(61, 42, 30, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.tb-run__box {
width: 300rpx;
padding: 44rpx 32rpx;
border-radius: 30rpx;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
gap: 22rpx;
}
.tb-run__ring {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 6rpx solid #ffe3d3;
border-top-color: #ff6b35;
animation: tbSpin 0.8s linear infinite;
}
.tb-run__text {
font-size: 26rpx;
font-weight: 600;
color: #3d2a1e;
}
.tb-run__bar {
width: 100%;
height: 12rpx;
border-radius: 999rpx;
background: #ffe3d3;
overflow: hidden;
}
.tb-run__fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f4d, #ff6b35);
transition: width 0.2s ease;
}
@keyframes tbSpin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* 参数滑块行(共享组件,收口原生 <slider> 布局)。
*
* 为什么需要它:
* 原生 `<slider>` 在 H5 运行时渲染为 `uni-slider`,其默认样式**不含 `width:100%`**,
* 内部 `.uni-slider-tap-area` 又是 `flex:1`(flex-basis 归零)。一旦把 `<slider>` 直接
* 当作 flex 子项,它的宽度会按内容收缩为 0 → 轨道不可见(只剩绝对定位的白色拨块);
* 且运行时拖动换算用 `wrapper.offsetWidth`,为 0 时结果 NaN/Infinity,**拖动也失效**。
*
* 解法:让**外层 `view` 承担 flex 布局**(`.tsr__field { flex: 1 }`),滑块作为它的
* 块级子元素并显式 `width:100% + margin:0`(清掉运行时默认 `margin:10px 18px`)。
*
* ⚠️ 严禁覆盖 `uni-slider` 的内部类(`.uni-slider-handle-wrapper` / `.uni-slider-thumb` …):
* H5 与 MP 结构不同,覆盖会跨端崩。只控制外层尺寸。
*/
withDefaults(defineProps<{
/** 完整显示文案(含数值后缀,由调用方格式化,如 "透明度" / "角度 30°") */
label: string
/** 当前值 */
value: number
/** 最小值,默认 0 */
min?: number
/** 最大值,默认 100 */
max?: number
/** 步长(支持小数,如 0.05),默认 1 */
step?: number
/** 拨块大小,默认 20 */
blockSize?: number
/** 标签宽度(可配),默认 150rpx */
labelWidth?: string
}>(), {
min: 0,
max: 100,
step: 1,
blockSize: 20,
labelWidth: '150rpx',
})
const emit = defineEmits<{ (e: 'change', value: number): void }>()
/** 直接抛出数字,调用方无需再解 e.detail.value */
function onChange(e: { detail: { value: number } }) {
emit('change', e.detail.value)
}
</script>
<template>
<view class="tsr">
<text class="tsr__label" :style="{ width: labelWidth }">{{ label }}</text>
<view class="tsr__field">
<slider
class="tsr__slider"
:min="min"
:max="max"
:step="step"
:value="value"
active-color="#ff6b35"
background-color="#ffe3d3"
:block-size="blockSize"
@change="onChange"
/>
</view>
</view>
</template>
<style scoped>
.tsr {
display: flex;
align-items: center;
gap: 24rpx;
}
.tsr__label {
flex-shrink: 0;
font-size: 24rpx;
color: #9a7b65;
}
/* ① 外层承担 flex:1 —— 避免原生 slider 作为 flex 子项塌成 0 宽 */
.tsr__field {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
/* ② 滑块自身撑满,并清掉运行时默认的 margin:10px 18px */
.tsr__slider {
flex: 1;
min-width: 0;
width: 100%;
margin: 0;
}
</style>

View File

@@ -13,6 +13,7 @@ import type { ToolQuota } from '@/api/quota'
import { computed, onMounted, ref, watch } from 'vue'
import { useQuotaStore } from '@/stores/quota'
import { currentToolKey, fetchToolQuota, goBuyPack, goMemberCenter, lastConsumedQuota } from '@/utils/quota'
import { Z } from '@/utils/z-index'
const props = withDefaults(defineProps<{
toolKey?: string
@@ -84,7 +85,7 @@ function buyMember() {
<view
v-if="quota"
class="qbar"
:style="{ bottom: `${props.bottom}rpx`, left: `${props.left}rpx` }"
:style="{ bottom: `${props.bottom}rpx`, left: `${props.left}rpx`, zIndex: Z.float }"
>
<!-- 展开:三池明细 -->
<view v-if="expanded" class="qpanel">
@@ -142,7 +143,6 @@ function buyMember() {
<style scoped>
.qbar {
position: fixed;
z-index: 890;
display: flex;
flex-direction: column;
align-items: flex-start;

View File

@@ -13,6 +13,7 @@ import { computed } from 'vue'
import { TOOL_REGISTRY } from '@/config/tools'
import { useQuotaStore } from '@/stores/quota'
import { goBuyPack, goMemberCenter, quotaGateState, toolDefOf } from '@/utils/quota'
import { Z } from '@/utils/z-index'
const store = useQuotaStore()
const state = computed(() => quotaGateState.value)
@@ -37,7 +38,7 @@ function buyPack() {
</script>
<template>
<view v-if="state.open" class="gate">
<view v-if="state.open" class="gate" :style="{ zIndex: Z.guide }">
<view class="gate__mask" @tap="close" />
<view class="gate__sheet">
@@ -118,7 +119,6 @@ function buyPack() {
right: 0;
top: 0;
bottom: 0;
z-index: 1500;
display: flex;
flex-direction: column;
justify-content: flex-end;

View File

@@ -67,7 +67,7 @@ function goMyQuota() {
</script>
<template>
<view class="vcard">
<view class="vcard tb-elev-gold">
<view class="vcard__aura" />
<view class="vcard__sweep" />
@@ -129,7 +129,7 @@ function goMyQuota() {
border-radius: 34rpx;
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 52%, #22130a 100%);
border: 2rpx solid rgba(245, 197, 107, 0.55);
box-shadow: 0 18rpx 42rpx rgba(43, 26, 16, 0.34), 0 0 0 6rpx rgba(245, 197, 107, 0.08);
/* 阴影/内高光来自公共 token .tb-elev-gold(R2-1) */
}
.vcard__aura {
position: absolute;

View File

@@ -14,6 +14,7 @@ import type { ToolQuota } from '@/api/quota'
import { computed, onMounted, ref } from 'vue'
import { useQuotaStore } from '@/stores/quota'
import { currentToolKey, fetchToolQuota, quotaSourceText } from '@/utils/quota'
import { Z } from '@/utils/z-index'
const props = withDefaults(defineProps<{
toolKey?: string
@@ -110,7 +111,7 @@ function goPack() {
<view
v-if="visible"
class="vipad"
:style="{ bottom: `${props.bottom}rpx`, right: `${props.right}rpx` }"
:style="{ bottom: `${props.bottom}rpx`, right: `${props.right}rpx`, zIndex: Z.float }"
>
<!-- 展开态:深色金边小卡 -->
<view v-if="expanded" class="panel">
@@ -172,7 +173,6 @@ function goPack() {
<style scoped>
.vipad {
position: fixed;
z-index: 900;
display: flex;
flex-direction: column;
align-items: flex-end;

View File

@@ -0,0 +1,142 @@
/**
* 图片工具页通用流程 composable(T02 脚手架)
*
* 把各图片工具页重复的「选图 → 处理中 → 结果 → 重跑」流程收敛到一处:
* - `onReady` 时调用 `bindImageCanvas(canvasSelector)` 绑定 MP 工作画布(H5 为空操作);
* - `running` 守卫:`doRun / rerun / pickAndRun / exec` 期间一律拒绝重入,**杜绝双击重复处理**;
* - `error`:处理失败时写入面向用户的文案(`msgOfError`),并默认 toast 一次;
* - 选择「用户取消」不当作错误(`isCancel`)。
*
* 引擎调用一律发生在用户交互之后(onReady 之后),不会出现「画布还没绑定就调引擎」。
*
* @example
* const tool = useImageTool({
* needCanvas: true,
* params: reactive({ tolerance: 25 }),
* run: (src, p) => removeBackgroundSolid(src, { tolerance: p.tolerance }),
* })
*/
import { onReady } from '@dcloudio/uni-app'
import { ref, type Ref } from 'vue'
import { bindImageCanvas, chooseOneImage } from '@/utils/image/core'
import { isCancel, msgOfError } from '@/utils/image/errors'
/** 通用图片产出(路径 + 命名) */
export interface ImageToolResult {
path: string
name: string
}
export interface UseImageToolOptions<TResult, TParams extends object> {
/** 处理函数:入参为当前选中图路径(无图时为 '')与参数对象 */
run: (src: string, params: TParams) => Promise<TResult>
/** 参数对象(响应式):页面绑定到 UI,`run` 内读取最新值 */
params?: TParams
/** MP 工作画布选择器,默认 `#imgCanvas` */
canvasSelector?: string
/** 选图后是否自动跑一次(`pickAndRun`),默认 true */
autoRun?: boolean
/** `doRun` 是否要求先有图(选图类工具 true,二维码等纯生成类 false),默认 true */
requireSrc?: boolean
}
export function useImageTool<
TResult = ImageToolResult,
TParams extends object = Record<string, unknown>,
>(options: UseImageToolOptions<TResult, TParams>) {
const params = (options.params ?? ({} as TParams)) as TParams
const canvasSelector = options.canvasSelector || '#imgCanvas'
const autoRun = options.autoRun !== false
const requireSrc = options.requireSrc !== false
/** 当前选中图路径(无图时为空串) */
const src = ref('')
/** 是否正在选图 */
const picking = ref(false)
/** 是否正在处理(重入守卫) */
const running = ref(false)
/** 最近一次处理结果 */
const result = ref<TResult | null>(null) as Ref<TResult | null>
/** 最近一次错误文案(面向用户) */
const error = ref('')
onReady(() => {
// 平台隔离由 core.bindImageCanvas 内部处理(MP 绑节点 / H5 返回 null),失败静默
bindImageCanvas(canvasSelector).catch(() => {})
})
/** 选图(不处理):成功返回图路径,取消/失败返回 null */
async function pick(): Promise<string | null> {
if (picking.value || running.value)
return null
picking.value = true
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
error.value = ''
return p.path
}
catch (e) {
if (!isCancel(e))
error.value = msgOfError(e)
return null
}
finally {
picking.value = false
}
}
/**
* 用 `running` 守卫包裹一段处理逻辑:失败统一转文案(可选 toast),成功返回结果。
* 供「保存」等页面自定义流程复用,避免与 `doRun` 的守卫/兜底逻辑重复。
*/
async function exec<T>(fn: () => Promise<T>, opts?: { silentError?: boolean }): Promise<T | null> {
if (running.value)
return null
running.value = true
error.value = ''
try {
return await fn()
}
catch (e) {
if (!isCancel(e)) {
error.value = msgOfError(e)
if (error.value && !opts?.silentError)
uni.showToast({ title: error.value, icon: 'none' })
}
return null
}
finally {
running.value = false
}
}
/** 处理当前选中图(`options.run`),成功写入 `result` */
async function doRun(): Promise<TResult | null> {
if (requireSrc && !src.value) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return null
}
return exec(async () => {
const r = await options.run(src.value, params)
result.value = r
return r
})
}
/** 选图并自动处理(`autoRun` 为真时) */
async function pickAndRun(): Promise<TResult | null> {
const p = await pick()
if (!p)
return null
return autoRun ? doRun() : null
}
/** 用当前参数重跑一次 */
async function rerun(): Promise<TResult | null> {
return doRun()
}
return { params, src, picking, running, result, error, exec, pick, pickAndRun, doRun, rerun }
}

279
src/config/card-styles.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* 卡片风格组件库配置(P2「瓷片区」)。
*
* 定位:只描述「卡片视觉风格(variant)」与「色系(tone)」以及「可被用户配置的目标清单」,
* 与 `config/features.ts` / `config/tools.ts` **解耦**(尤其绝不进 `TOOL_REGISTRY`,
* 否则会被后端 tools 表判 `locked:true` 静默锁死,见 features.ts 顶部注释)。
*
* 真正的样式实现全部在 `components/card/CardTile.vue` 的 <style> 内**以 class 切分**;
* 本文件只给出「风格 id / 展示名 / 设置页小样色」与「色系双色渐变端点 + 彩色环境投影色」,
* 由 CardTile 以 CSS 变量注入、由 class 消费 —— 保证「风格在样式表里、数据在配置里」。
*/
/** 色系(porcelain 用的双色 pastel 渐变端点 + 各 variant 需要的主色) */
export interface CardTone {
/** 色系 id */
id: string
/** 展示名 */
label: string
/** porcelain 渐变起点 */
from: string
/** porcelain 渐变终点 */
to: string
/** 主色:图标色 / line 描边取用(**文字/图标**用深色,勿作渐变底) */
accent: string
/** 强渐变起点(solid 皮肤 / duo 版式专用,饱和亮色,避免用 accent 发暗) */
gradFrom: string
/** 强渐变终点(solid 皮肤 / duo 版式专用) */
gradTo: string
/** 图标块底色(白色圆角块,带极淡色) */
iconBg: string
/** tint 变体的纯色 pastel 底(**等价于旧 .feat 的 fe.bg**,保证向后兼容) */
tintBg: string
/** line 变体的描边色 */
lineBorder: string
/** 角标底色(高对比 chip;勿用固定金色,否则与 gold 卡同色不可见) */
badgeBg: string
/** 角标字色(取同色系深字,保证在任意卡底色上都清晰) */
badgeFg: string
/** 彩色环境投影(用本卡色相的低透明色,替代灰黑阴影——参考图质感的关键) */
shadow: string
/** 层叠卡(stack)的第 1 层厚度色 */
layer: string
/** 层叠卡(stack)的第 2 层厚度色(更淡) */
layerSoft: string
}
/** 卡片风格(组件库成员;每条含设置页画小样所需的前景色) */
export interface CardVariant {
/** 风格 id(= CardTile 的 class 后缀 `ct--skin-<id>`) */
id: string
/** 展示名 */
label: string
/** 设置页小样(预览)用色;仅作示意,真实渲染由 CardTile 的 class 决定 */
preview: { bg: string, fg: string, border?: string }
}
/** 5 种风格:porcelain(默认,= 参考「瓷片区」)/ tint(= 旧样式,向后兼容)/ glass / solid / line */
export const CARD_VARIANTS: CardVariant[] = [
{ id: 'porcelain', label: '瓷片', preview: { bg: 'linear-gradient(135deg, #FFE9C7, #F7C58A)', fg: '#8A5A00' } },
{ id: 'tint', label: '淡彩', preview: { bg: '#FFF4D6', fg: '#B57F00' } },
{ id: 'glass', label: '玻璃', preview: { bg: 'rgba(255,255,255,0.7)', fg: '#6B4F3A', border: 'rgba(255,255,255,0.95)' } },
{ id: 'solid', label: '实心', preview: { bg: 'linear-gradient(135deg, #FF9F4D, #FF6B35)', fg: '#FFFFFF' } },
{ id: 'line', label: '极简', preview: { bg: '#FFFFFF', fg: '#3D2A1E', border: '#FFE3D3' } },
]
/** 色系表 */
export const CARD_TONES: CardTone[] = [
{
id: 'gold',
label: '金',
from: '#FFEACC',
to: '#FFD08F',
accent: '#8A5A00',
gradFrom: '#FFB84D',
gradTo: '#FF7A45',
iconBg: 'rgba(255,255,255,0.85)',
tintBg: '#FFF4D6',
lineBorder: 'rgba(180,127,0,0.35)',
badgeBg: '#FFFFFF',
badgeFg: '#8A5A00',
shadow: 'rgba(255,138,51,0.36)',
layer: 'rgba(255,178,102,0.62)',
layerSoft: 'rgba(255,205,150,0.42)',
},
{
id: 'blue',
label: '蓝',
from: '#CCE5FF',
to: '#8FC5FF',
accent: '#0B4C8C',
gradFrom: '#4DA3FF',
gradTo: '#2E6BE6',
iconBg: 'rgba(255,255,255,0.9)',
tintBg: '#E3F2FF',
lineBorder: 'rgba(22,119,217,0.32)',
badgeBg: '#FFFFFF',
badgeFg: '#0B4C8C',
shadow: 'rgba(46,107,230,0.32)',
layer: 'rgba(122,170,255,0.62)',
layerSoft: 'rgba(168,200,255,0.42)',
},
{
id: 'mint',
label: '薄荷',
from: '#CCFFF1',
to: '#8FFFDF',
accent: '#0B7A66',
gradFrom: '#35CFA8',
gradTo: '#12A07E',
iconBg: 'rgba(255,255,255,0.9)',
tintBg: '#DFF7F0',
lineBorder: 'rgba(15,163,133,0.32)',
badgeBg: '#FFFFFF',
badgeFg: '#0B7A66',
shadow: 'rgba(18,160,126,0.30)',
layer: 'rgba(94,214,180,0.62)',
layerSoft: 'rgba(150,230,205,0.42)',
},
{
id: 'rose',
label: '玫瑰',
from: '#FFCCD3',
to: '#FF8F9E',
accent: '#9E2B45',
gradFrom: '#FF8FA3',
gradTo: '#F2637F',
iconBg: 'rgba(255,255,255,0.9)',
tintBg: '#FFE1E6',
lineBorder: 'rgba(158,43,69,0.3)',
badgeBg: '#FFFFFF',
badgeFg: '#9E2B45',
shadow: 'rgba(242,99,127,0.32)',
layer: 'rgba(255,150,170,0.62)',
layerSoft: 'rgba(255,190,205,0.42)',
},
{
id: 'violet',
label: '紫',
from: '#DBCCFF',
to: '#B08FFF',
accent: '#5B2E9E',
gradFrom: '#A98BFF',
gradTo: '#7B5BF0',
iconBg: 'rgba(255,255,255,0.9)',
tintBg: '#ECE2FF',
lineBorder: 'rgba(91,46,158,0.3)',
badgeBg: '#FFFFFF',
badgeFg: '#5B2E9E',
shadow: 'rgba(123,91,240,0.32)',
layer: 'rgba(176,148,255,0.62)',
layerSoft: 'rgba(210,192,255,0.42)',
},
]
// ============================================================================
// P3 · 首页区块清单 + 样式三元组 + 目标寻址
// ============================================================================
/** 单张卡片/区块的样式三元组(版式 + 皮肤 + 色系) */
export interface CardStyle {
/** 版式,见 CARD_TEMPLATES 的 id */
template: string
/** 皮肤,见 CARD_VARIANTS 的 id */
variant: string
/** 色系,见 CARD_TONES 的 id */
tone: string
}
/**
* 运行时成员来源标识。
* 静态 `members` 只能覆盖编译期已知的目标;而首页大量卡片是**运行时动态**的
* (工作台固定工具 / 发现工具 / 各模块楼层工具,均来自后端 tools 表 + 用户配置)。
* 故给区块加「运行时成员来源」,由设置抽屉在打开时去 Pinia store 取**当前真实入口**。
*/
export type MemberSource = 'features' | 'workbench' | 'hot' | 'floors'
/** 首页区块(每个区块一条,含默认样式);members = 该区块显式归属的具体卡片目标 */
export interface CardSection {
/** 区块 key(可用 `section.<key>` 寻址) */
key: string
/** 展示名 */
label: string
/** 区块默认样式(解析顺序第二级) */
default: CardStyle
/** 静态成员(编译期已知的小量目标,如 `feature.note`) */
members: string[]
/** 运行时成员来源:抽屉打开时按此去 store 取当前真实入口(动态成员,见 MemberSource) */
memberSource?: MemberSource
/** 区块级说明(无逐入口清单时的行内提示,如「搜索结果为动态列表…」) */
hint?: string
/**
* 该区块**允许**的版式白名单(缺省 = 全部 9 种)。
*
* 为什么必须约束(R5 真事故):
* 1. `CardTile` 只有 **group(分组容器卡)** 渲染默认插槽,其余版式一律丢弃插槽内容。
* 于是「工具库·模块楼层」被配成图标墙后,楼层里的工具小卡**整段消失**
* (角标仍显示「N 个工具」,下方却空白);
* 2. 窄列(2 列 / 4 列网格)配成 **lead(通栏主卡)** 时,右端固定 168rpx 插画位会挤压文案,
* 标题/描述被挤成逐字换行,两张卡高度还随文案长短不一致。
* 故对「容器区块」与「窄列区块」做白名单约束;store 侧另有兜底钳制,
* 使**已存在本地的越界配置自动回落**,不必清缓存。
*/
templates?: string[]
}
/** 全局默认(解析顺序末级兜底;仅在「区块默认也拿不到」时命中) */
export const GLOBAL_DEFAULT_STYLE: CardStyle = {
template: 'iconTop',
variant: 'tint',
tone: 'gold',
}
/**
* 版式白名单三档(配合 `CardSection.templates`):
* - `TPL_CONTAINER` 容器区块(承载子卡插槽)→ 只能 group,否则插槽被丢弃;
* - `TPL_NARROW` 窄列区块(2 列 / 4 列网格)→ 排除 group(丢插槽)与 lead(通栏主卡,窄列逐字换行);
* 含 `tile`(窄列瓷片);
* - `TPL_FULL` 通栏区块 → 除 group 与 tile(窄列瓷片)外均可(full 宽单卡放 group 只会剩一个标题,也无插槽内容)。
*/
const TPL_CONTAINER = ['group']
const TPL_NARROW = ['iconTop', 'iconLeft', 'duo', 'tile', 'notch', 'arch', 'leaf', 'stack']
const TPL_FULL = ['iconTop', 'iconLeft', 'lead', 'duo', 'notch', 'arch', 'leaf', 'stack']
/**
* 首页区块清单(按源码位置登记;key 见派单表)。
* 默认样式偏「瓷片区」观感:主卡/发现用渐变插画版式,宫格用图标墙。
*
* 注:「我的」页已**不再使用卡片风格体系**(相关页面正回滚),
* 故 `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: '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.tile', label: '工具库 · 楼层工具小卡', default: { template: 'iconTop', variant: 'tint', tone: 'blue' }, members: [], memberSource: 'floors', templates: TPL_NARROW, hint: '楼层里的工具小卡:默认「图标在上、文字横排在下」(窄列,不含容器版式)' },
]
/** 取某区块允许的版式白名单(未登记 = undefined = 不限制) */
export function sectionTemplates(secKey?: string): string[] | undefined {
if (!secKey)
return undefined
return cardSection(secKey)?.templates
}
/** 按 key 取区块;未知返回 undefined */
export function cardSection(key: string): CardSection | undefined {
return CARD_SECTIONS.find(s => s.key === key)
}
/**
* 目标寻址 → 所属区块 key(解析顺序第二级用)。
*
* 约定(后续扩展靠它):
* - `section.<key>` 直接指向该区块;
* - `feature.<key>` 归属 `workbench.features`;
* - `tool.<tool_key>` 默认归属 `library.grid`(工具库宫格);
* - 若某区块 members 显式声明了该目标,优先按 members 命中。
*
* ⚠️ 注意:`tool.<k>` 的归属是**回落猜测**。楼层子卡(`library.tile`)等**同名工具跨区块**的场景,
* 必须由调用方**显式传入 section**(`PaneLibrary` 传 `library.tile`)寻址,**勿依赖此回落**
* (否则会被误判为 `library.grid`,导致「配了不生效」)。
*/
export function sectionKeyOf(targetKey: string): string | undefined {
if (targetKey.startsWith('section.'))
return targetKey.slice('section.'.length)
const owned = CARD_SECTIONS.find(s => s.members.includes(targetKey))
if (owned)
return owned.key
if (targetKey.startsWith('feature.'))
return 'workbench.features'
if (targetKey.startsWith('tool.'))
return 'library.grid'
return undefined
}

View File

@@ -0,0 +1,118 @@
/**
* 卡片版式(模板)登记表(P3「瓷片区」重做)。
*
* 与 `card-styles.ts`(皮肤/色系/区块)**分工**:
* - 本文件 = **版式**(骨架布局):图标墙 / 行卡 / 通栏主卡 / 双列渐变卡 / 分组容器卡 +
* 4 种**异形**卡(缺角 / 拱形 / 叶形 / 层叠——只改形状与质感,内容仍走 iconTop 骨架);
* - card-styles.ts = **皮肤(variant)+ 色系(tone)+ 区块(section)**。
* 版式与皮肤**正交**:`CardTile` 的根元素同时带 `.ct--<template>` 与 `.ct--skin-<variant>`。
*
* 真正样式实现在 `components/card/CardTile.vue` 的 <style> 内以 class 切分;
* 本文件只给「id / 展示名 / 说明 / 设置页小样色 + 缩略预览的内容排布」。
*
* 缩略预览(设置抽屉「版式」行)分工:
* - **形状圆角**(缺口/拱形/叶形/层叠等)属样式 → 放 `MineDrawers.vue` 的 wxss 类(按 id 精确复刻 CardTile);
* - **内容排布**(上块下条 / 左块右条 / 容器网格…)属结构数据 → 放本文件的 `preview.thumb.layout`。
*/
/** 设置抽屉「版式」缩略预览的内容排布(与 CardTile 各版式骨架一致) */
export type TemplateThumbLayout = 'top' | 'left' | 'lead' | 'duo' | 'group' | 'tile'
/** 版式小样(设置页色块 / 缩略预览) */
export interface CardTemplatePreview {
/** 缩略底色(**既有字段**,其他代码在用,勿改) */
bg: string
/** 缩略前景(**既有字段**,其他代码在用,勿改) */
fg: string
/** 缩略内容排布(可选;未给出时抽屉按 'top' 处理) */
thumb?: { layout: TemplateThumbLayout }
}
export interface CardTemplate {
/** 版式 id(= CardTile class 后缀 `ct--<id>`) */
id: 'iconTop' | 'iconLeft' | 'lead' | 'duo' | 'group' | 'notch' | 'arch' | 'leaf' | 'stack' | 'tile'
/** 中文名(设置页展示) */
label: string
/** 一句话说明(设置页副标题) */
desc: string
/** 设置页小样(预览)色 + 缩略内容排布 */
preview: CardTemplatePreview
}
/**
* 10 种版式:
* - 常规 6 种(对齐参考图「瓷片区卡片设计」):iconTop / iconLeft / lead / duo / group / tile;
* - 异形 4 种(P3 增量):notch 缺角 / arch 拱形 / leaf 叶形 / stack 层叠——
* 内容骨架走 iconTop,与 5 个皮肤自动正交(零额外渲染分支)。
*
* `tile`(瓷片子卡,R7 新增):走 iconTop 骨架 + `.ct--tile` 修饰 class(零新增渲染分支),
* 定位「窄列瓷片」= 左上角标 + 标题 + 右下插画,供工具库楼层子卡等窄列选用。
*/
export const CARD_TEMPLATES: CardTemplate[] = [
{
id: 'iconTop',
label: '图标墙',
desc: '大号图标在上 + 标题 + 描述(宫格)',
preview: { bg: 'linear-gradient(135deg, #E7F0FF, #C9DEFF)', fg: '#2E5BFF', thumb: { layout: 'top' } },
},
{
id: 'iconLeft',
label: '行卡',
desc: '图标左置 + 标题/描述在右',
preview: { bg: 'linear-gradient(135deg, #EAF7F1, #C8EEDD)', fg: '#0B7A66', thumb: { layout: 'left' } },
},
{
id: 'lead',
label: '通栏主卡',
desc: '左侧文案 + 右侧大插画',
preview: { bg: 'linear-gradient(135deg, #FFF1DC, #FFD9A8)', fg: '#B26A00', thumb: { layout: 'lead' } },
},
{
id: 'duo',
label: '双列渐变卡',
desc: '强渐变底 + 插画 + 标题 + 描述',
preview: { bg: 'linear-gradient(135deg, #FF8FCB, #B07CFF)', fg: '#FFFFFF', thumb: { layout: 'duo' } },
},
{
id: 'group',
label: '分组容器卡',
desc: '白底容器 + 区块标题 + 右上角标 + 子卡网格',
preview: { bg: '#FFFFFF', fg: '#3D2A1E', thumb: { layout: 'group' } },
},
{
id: 'notch',
label: '缺角卡',
desc: '左上直角 + 大圆角,异形轮廓',
// 异形复用 iconTop 骨架 → 缩略排布同为 top,形状(圆角)由抽屉 wxss 类表达
preview: { bg: 'linear-gradient(140deg, #FFE9C7, #F7C58A)', fg: '#8A5A00', thumb: { layout: 'top' } },
},
{
id: 'arch',
label: '拱形卡',
desc: '顶部高拱门形,适合图标在上',
preview: { bg: 'linear-gradient(140deg, #DCEEFF, #B7D8FF)', fg: '#0B4C8C', thumb: { layout: 'top' } },
},
{
id: 'leaf',
label: '叶形卡',
desc: '对角异形轮廓,轻盈',
preview: { bg: 'linear-gradient(140deg, #D8F7EC, #A8ECD4)', fg: '#0B7A66', thumb: { layout: 'top' } },
},
{
id: 'stack',
label: '层叠卡',
desc: '多层位移投影,堆叠厚度感',
preview: { bg: 'linear-gradient(140deg, #ECE2FF, #CFB8FF)', fg: '#5B2E9E', thumb: { layout: 'top' } },
},
{
id: 'tile',
label: '瓷片子卡',
desc: '左上角标 + 标题 + 右下插画',
preview: { bg: 'linear-gradient(140deg,#DFF7F0,#A8ECD4)', fg: '#0B7A66', thumb: { layout: 'tile' } },
},
]
/** 版式是否存在(供 store 校验) */
export function isKnownTemplate(id: string): boolean {
return CARD_TEMPLATES.some(t => t.id === id)
}

80
src/config/features.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* 功能入口注册表(**独立于** 工具注册表 `config/tools.ts`)。
*
* 为什么不登记进 `TOOL_REGISTRY`:
* `stores/tools.ts` 会把「不在服务端 `tools` 表中的 key」判为 `locked: true`(`?? true`),
* 且工作台 `workbenchTools()` 要求「前端 def 与后端 tools 行同时存在」——强行把笔记/年会
* 塞进 `TOOL_REGISTRY` 会让入口「静默锁死」(页面在但点了报未授权)。
* 故功能入口单独维护、直接本地路由跳转,与工具库楼层解耦。
* 依据:架构 §8.6 决策 10、PRD-02 R6-1(只要求「首页/我的页可入口」,不要求进工具库)。
*
* 新增功能入口:在 `FEATURE_ENTRIES` 追加一条即可,`PaneMine.vue` 会自动渲染。
*/
export interface FeatureEntry {
/** 唯一键(仅前端使用,**不是** tool_key) */
key: string
/** 入口名称 */
name: string
/** wot 图标名 */
icon: string
/** 卡片插画 key(见 Sticker.vue 的 SVGS;用于首页便捷功能卡的 iconTop 插画位) */
illustration: string
/** 入口说明(我的页右侧文案) */
desc: string
/** 本地路由(分包页用绝对路径) */
path: string
/** 图标块底色(沿用「我的页」淡色轮换) */
bg: string
/** 图标色 */
color: string
/** 卡片色系 id(见 config/card-styles.ts 的 CARD_TONES);未配置时回落区块默认 */
tone: string
}
/**
* 微信订阅消息模板 ID(笔记定时提醒)。
*
* ⚠️ 必须与后端 `manifest/config/config.yaml` 的 `wx.subscribe.noteRemindTemplateId` 保持一致。
* 为空时的降级(两端对称、绝不报错):
* - 前端:不发起 `uni.requestSubscribeMessage`(无 tmplId 调微信会直接报错);
* - 后端:到点只写站内提醒标记 + 打日志(PRD-02 R6-12、架构 §8.3)。
* 此时提醒仍会保存,只是「仅在应用内可见」。
*/
export const NOTE_REMIND_TMPL_ID = ''
/** 功能入口列表(我的页渲染来源) */
export const FEATURE_ENTRIES: FeatureEntry[] = [
{
key: 'note',
name: '我的笔记',
icon: 'edit-outline',
illustration: 'note',
desc: '随手记录 · 定时提醒',
path: '/pkg-note/list',
bg: '#FFF4D6',
color: '#B57F00',
tone: 'gold',
},
{
key: 'event',
name: '年会查座',
icon: 'location',
illustration: 'pin',
desc: '选活动查座位',
path: '/pkg-event/list',
bg: '#E3F2FF',
color: '#1677D9',
tone: 'blue',
},
{
key: 'cardLibrary',
name: '卡片样式库',
icon: 'photo',
illustration: 'sparkle',
desc: '20 组卡片设计复刻',
path: '/pkg-style/showcase',
bg: '#ECE2FF',
color: '#5B2E9E',
tone: 'violet',
},
]

View File

@@ -53,6 +53,9 @@
"setting": {
"urlCheck": false
},
"optimization": {
"subPackages": true
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"

View File

@@ -4,7 +4,7 @@
"path": "pages/index/index",
"type": "home",
"style": {
"enablePullDownRefresh": true,
"enablePullDownRefresh": false,
"backgroundTextStyle": "dark",
"backgroundColor": "#FFF9F2"
}
@@ -84,6 +84,12 @@
"backgroundTextStyle": "dark"
}
},
{
"path": "member/detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "quota/index",
"style": {
@@ -95,6 +101,59 @@
"style": {
"navigationBarTitleText": "我的额度"
}
},
{
"path": "coupon/index",
"style": {
"navigationBarTitleText": "我的优惠券",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
}
]
},
{
"root": "pkg-note",
"pages": [
{
"path": "list",
"style": {
"navigationBarTitleText": "我的笔记"
}
},
{
"path": "edit",
"style": {
"navigationBarTitleText": "编辑笔记"
}
}
]
},
{
"root": "pkg-event",
"pages": [
{
"path": "list",
"style": {
"navigationBarTitleText": "年会活动"
}
},
{
"path": "query",
"style": {
"navigationBarTitleText": "年会查座"
}
}
]
},
{
"root": "pkg-style",
"pages": [
{
"path": "showcase",
"style": {
"navigationBarTitleText": "卡片样式库"
}
}
]
}
@@ -102,7 +161,7 @@
"preloadRule": {
"pages/index/index": {
"network": "wifi",
"packages": ["pkg-member"]
"packages": ["pkg-member", "pkg-note", "pkg-style"]
}
}
}

View File

@@ -1,34 +1,60 @@
<script setup lang="ts">
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
/**
* 首页(三个 pane + 悬浮 TabBar)。
*
* refresher 下沉(承前 T05.1/2 + N5):每个 pane **根元素即 scroll-view**、自持 `active` prop,
* 并**按域暴露 `refresh()`**——本页只负责切 tab 与在 `onShow` 时刷新**当前** pane,
* 不再统一 `loadData()`(那会让工具库/我的也去打 /quota/my)。
*/
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import PaneLibrary from '@/components/home/PaneLibrary.vue'
import PaneMine from '@/components/home/PaneMine.vue'
import PaneWorkbench from '@/components/home/PaneWorkbench.vue'
import MineDrawers from '@/components/mine/MineDrawers.vue'
import TabBar from '@/components/TabBar.vue'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
type TabKey = 'workbench' | 'library' | 'mine'
/** pane 对外暴露的能力(按域刷新) */
interface PaneExpose {
refresh: () => Promise<void>
}
const TABS: Array<{ key: TabKey, title: string, icon: string }> = [
{ key: 'workbench', title: '工作台', icon: 'home' },
{ key: 'library', title: '工具库', icon: 'app' },
{ key: 'mine', title: '我的', icon: 'user' },
]
const toolsStore = useToolsStore()
const userStore = useUserStore()
const active = ref<TabKey>('workbench')
/** 退场中的 tab(动画期间保持渲染) */
const leaving = ref<TabKey | null>(null)
/** 切换方向:1 向右进、-1 向左进,决定进场滑块方向 */
const direction = ref<1 | -1>(1)
/** 首屏数据是否就绪(失败也置 true,展示空态而非永久 loading) */
const ready = ref(false)
let leaveTimer: ReturnType<typeof setTimeout> | undefined
const wbRef = ref<PaneExpose | null>(null)
const libRef = ref<PaneExpose | null>(null)
const mineRef = ref<PaneExpose | null>(null)
const paneRefs: Record<TabKey, typeof wbRef> = { workbench: wbRef, library: libRef, mine: mineRef }
/**
* 「我的」底部抽屉(绑定账号 / 意见反馈)**页面根级**挂载点。
* 抽屉不在 pane 的 scroll-view 子树内,规避 N5 的层叠/裁剪陷阱(详见 MineDrawers.vue)。
*/
const drawersRef = ref<{ openFeedback: () => void, openBind: () => void, openCardStyle: () => void } | null>(null)
function openFeedbackDrawer() {
drawersRef.value?.openFeedback()
}
function openBindDrawer() {
drawersRef.value?.openBind()
}
function openCardStyleDrawer() {
drawersRef.value?.openCardStyle()
}
function switchTab(key: string) {
const next = key as TabKey
if (next === active.value)
@@ -51,68 +77,56 @@ function paneClass(key: TabKey) {
}
}
async function loadData() {
try {
await userStore.ensureLogin()
await toolsStore.fetchAll()
}
catch {
/* 静默失败:页面展示空态 */
}
finally {
ready.value = true
}
}
/** 只刷新「当前」pane(按其域);首屏各 pane 已在激活时自加载,故首次 onShow 跳过 */
let firstShow = true
onShow(() => {
loadData()
})
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
if (firstShow) {
firstShow = false
return
}
paneRefs[active.value].value?.refresh?.()
})
</script>
<template>
<view class="home">
<view class="home__stage">
<scroll-view
<PaneWorkbench
v-show="active === 'workbench' || leaving === 'workbench'"
scroll-y
class="pane"
ref="wbRef"
:active="active === 'workbench'"
:class="paneClass('workbench')"
:show-scrollbar="false"
>
<PaneWorkbench :ready="ready" @library="switchTab('library')" />
</scroll-view>
@library="switchTab('library')"
/>
<scroll-view
<PaneLibrary
v-show="active === 'library' || leaving === 'library'"
scroll-y
class="pane"
ref="libRef"
:active="active === 'library'"
:class="paneClass('library')"
:show-scrollbar="false"
>
<PaneLibrary />
</scroll-view>
/>
<scroll-view
<PaneMine
v-show="active === 'mine' || leaving === 'mine'"
scroll-y
class="pane"
ref="mineRef"
:active="active === 'mine'"
:class="paneClass('mine')"
:show-scrollbar="false"
>
<PaneMine />
</scroll-view>
@feedback="openFeedbackDrawer"
@bind="openBindDrawer"
@cardstyle="openCardStyleDrawer"
/>
</view>
<TabBar :tabs="TABS" :active="active" @change="switchTab" />
<!-- 我的抽屉:**页面根级**(与 TabBar 同级)渲染,z-index(2000) > TabBar(1000),必然盖住 -->
<MineDrawers ref="drawersRef" />
</view>
</template>
<style scoped>
/* pane 的绝对定位铺满与进出场动画是**全局**样式(见 App.vue「首页 pane」),
这样父组件下的 class 才能命中「子 pane 组件的根元素 = scroll-view」。此处只留页面骨架。 */
.home {
height: 100vh;
overflow: hidden;
@@ -121,52 +135,4 @@ onPullDownRefresh(async () => {
position: relative;
height: 100%;
}
/* 三个 tab 视图绝对定位铺满,各自独立滚动位置 */
.pane {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* 先声明退场、后声明进场:同屏带两个类时进场动画优先 */
.pane--out {
animation: tbPaneOut 0.3s ease both;
}
/* fill 用 backwards:动画结束后 transform 归位 none,
避免永久保留层叠上下文/包含块,把 pane 内 fixed 弹层困住 */
.pane--in {
animation: tbPaneIn 0.36s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
}
.pane--back.pane--in {
animation-name: tbPaneInBack;
}
@keyframes tbPaneIn {
from {
opacity: 0;
transform: translateX(48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneInBack {
from {
opacity: 0;
transform: translateX(-48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>

View File

@@ -5,6 +5,7 @@ import Sticker from '@/components/Sticker.vue'
import { useUserStore } from '@/stores/user'
import { AVATAR_PRESETS, presetOf } from '@/utils/avatar'
import { navPadTop } from '@/utils/safearea'
import { Z } from '@/utils/z-index'
const userStore = useUserStore()
/** 导航栏顶部留白:MP 用真实状态栏高度 */
@@ -13,6 +14,8 @@ const navPad = navPadTop()
const nickname = ref('')
const avatarUrl = ref('')
const saving = ref(false)
/** 微信头像上传中 */
const uploading = ref(false)
function syncFromStore() {
const u = userStore.user
@@ -31,6 +34,34 @@ function pickAvatar(key: string) {
avatarUrl.value = key
}
/**
* 微信头像(MP):<button open-type="chooseAvatar"> 回调拿到**临时路径**,
* 立即上传到后端换成**我方持久 URL**(换设备仍可显示),成功后才写回待保存项。
* 超过 5MB / 非图片 → 后端 4016:提示且**不改动原头像**。
*/
async function onChooseAvatar(e: unknown) {
const temp = (e as { detail?: { avatarUrl?: string } })?.detail?.avatarUrl
if (!temp || uploading.value)
return
uploading.value = true
try {
const url = await userStore.uploadAvatar(temp)
avatarUrl.value = url
uni.showToast({ title: '头像已选择', icon: 'success' })
}
catch (err) {
const code = (err as { code?: number })?.code
if (code === 4016)
uni.showModal({ title: '头像不符合要求', content: '请选择 5MB 以内的图片', showCancel: false })
else
uni.showToast({ title: (err as Error)?.message || '头像上传失败,请重试', icon: 'none' })
// 失败时不动 avatarUrl,保留原头像
}
finally {
uploading.value = false
}
}
async function save() {
const name = nickname.value.trim()
if (!name) {
@@ -82,6 +113,21 @@ function goBack() {
<text class="card__title">
选择头像
</text>
<!-- MP:只允许「默认」或「微信头像」两种,不提供自建上传入口 -->
<!-- #ifdef MP-WEIXIN -->
<button
class="wx-avatar press"
hover-class="press-active"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
<wd-icon name="chat" size="34rpx" color="#1677D9" />
<text class="wx-avatar__text">{{ uploading ? '上传中…' : '使用微信头像' }}</text>
</button>
<!-- #endif -->
<text class="card__hint">
{{ '默认头像' }}
</text>
<view class="avatars">
<view
v-for="p in AVATAR_PRESETS"
@@ -108,14 +154,15 @@ function goBack() {
<input
v-model="nickname"
class="nickname"
placeholder="给自己起个名字"
type="nickname"
placeholder="点击可一键使用微信昵称"
placeholder-style="color:#C9AE9C"
:maxlength="32"
>
</view>
<!-- 底部悬浮保存 -->
<view class="save-bar">
<view class="save-bar" :style="{ zIndex: Z.stickyBar }">
<wd-button
type="primary"
block
@@ -200,6 +247,36 @@ function goBack() {
color: #3d2a1e;
margin-bottom: 20rpx;
}
.card__hint {
display: block;
font-size: 22rpx;
color: #b3907c;
margin-bottom: 16rpx;
}
/* 微信头像按钮:仅 MP 展示;按钮默认有边框,覆盖成卡片内联样式 */
.wx-avatar {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
width: 100%;
box-sizing: border-box;
height: 88rpx;
margin: 0 0 24rpx;
padding: 0;
border-radius: 999rpx;
background: #e3f2ff;
border: 2rpx solid #bcdcff;
line-height: 1;
}
.wx-avatar::after {
border: none;
}
.wx-avatar__text {
font-size: 27rpx;
font-weight: 700;
color: #1677d9;
}
.avatars {
display: grid;
grid-template-columns: repeat(6, 1fr);
@@ -248,6 +325,5 @@ function goBack() {
left: 32rpx;
right: 32rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom, 0px));
z-index: 8;
}
</style>

View File

@@ -76,6 +76,14 @@ async function initCanvas(cssW: number, cssH: number) {
el.height = ch * dpr
canvasNode = el
ctx = el.getContext('2d')
// ⚠️ H5 专属:uni-h5 运行时 `initHidpi()` 会 monkey-patch `CanvasRenderingContext2D.prototype`,
// 对所有 `__hidpi__ === true` 的 context 把绘制坐标(fillRect/drawImage/fillText/arc/...)自动
// ×devicePixelRatio;uni 的 canvas 组件(hidpi 默认 true)会给本内层 canvas 的 ctx 打上该标记。
// 本页的 逻辑坐标→高分辨率位图 映射已由下面的 `scale(dpr, dpr)` 完成,若再叠加 uni 的 ×dpr 会变成
// **dpr²**:DPR≥2 时编辑画布与「保存到相册」的导出图都会被裁(只剩左上角放大,DPR=3 时整幅全红)。
// 故此处显式关掉 uni 的这层自动缩放;用条件编译隔离,**MP 端不受影响**(MP 无此补丁)。
if (ctx)
(ctx as any).__hidpi__ = false
ctx?.scale(dpr, dpr)
}
resolve()

View File

@@ -3,6 +3,7 @@ import type { FxShape } from '@/utils/image/avatarfx'
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import Sticker from '@/components/Sticker.vue'
import ToolSliderRow from '@/components/tool/ToolSliderRow.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { AVATAR_EFFECTS, drawEffect, drawPhoto } from '@/utils/image/avatarfx'
import { beginDraw, bindImageCanvas, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
@@ -182,35 +183,29 @@ function goBack() {
<!-- 缩放 -->
<view class="row">
<text class="row__label">
照片缩放
</text>
<slider
<ToolSliderRow
class="row__slider"
label="照片缩放"
:value="zoom"
:min="1"
:max="2"
:step="0.05"
:value="zoom"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (zoom = e.detail.value)"
label-width="140rpx"
@change="(v: number) => (zoom = v)"
/>
</view>
<!-- 描边粗细 -->
<view class="row">
<text class="row__label">
描边粗细
</text>
<slider
<ToolSliderRow
class="row__slider"
label="描边粗细"
:value="ringScale"
:min="0.6"
:max="1.6"
:step="0.05"
:value="ringScale"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (ringScale = e.detail.value)"
label-width="140rpx"
@change="(v: number) => (ringScale = v)"
/>
</view>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import ToolSliderRow from '@/components/tool/ToolSliderRow.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { COLLAGE_TEMPLATES, drawCollage } from '@/utils/image/collage'
import { beginDraw, bindImageCanvas, chooseImages, exportFile, loadImage, saveResult } from '@/utils/image/core'
@@ -162,18 +163,15 @@ function goBack() {
<!-- 间距 -->
<view class="row">
<text class="row__label">
间距
</text>
<slider
<ToolSliderRow
class="row__slider"
label="间距"
:value="gap"
:min="0"
:max="48"
:step="4"
:value="gap"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (gap = e.detail.value)"
label-width="110rpx"
@change="(v: number) => (gap = v)"
/>
</view>

View File

@@ -1,201 +1,367 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
/**
* 去除背景(T04)
*
* 布局走 `ToolPage`,流程走 `useImageTool`(onReady 绑画布 + running 防双击)。
* 纯色模式双端可用;智能模式为网页版能力(PRD-01 R5-1/R5-2):
* - 小程序端保留入口,点击给「仅网页版可用」解释弹窗(不是报错);
* - 网页端未配置模型时给「能力暂未开放」提示(不是报错)。
* 参数(容差 / 羽化强度 / 背景)带本地记忆;背景一键切换,保存与预览一致。
*/
import { computed, reactive, ref, watch } from 'vue'
import ToolIdle from '@/components/tool/ToolIdle.vue'
import ToolPage from '@/components/tool/ToolPage.vue'
import ToolRunning from '@/components/tool/ToolRunning.vue'
import ToolSliderRow from '@/components/tool/ToolSliderRow.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { removeBackgroundSmart, removeBackgroundSolid } from '@/utils/image/matting'
import { useImageTool } from '@/composables/useImageTool'
import { saveResult } from '@/utils/image/core'
import {
applyBackground,
type MattingBg,
removeBackgroundSmart,
removeBackgroundSolid,
SMART_MODEL_URL,
} from '@/utils/image/matting'
import { resizeImage } from '@/utils/image/resize'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const tolerance = ref(25)
const result = ref<{ path: string, name: string } | null>(null)
const busy = ref(false)
type Mode = 'solid' | 'smart'
interface MattingParams {
mode: Mode
tolerance: number
feather: number
bg: MattingBg
}
const PARAM_KEY = 'tb_matting_params_v1'
const PREVIEW_MAX = 1024
/** 参数(含本地记忆:容差 / 羽化 / 背景) */
const P = reactive<MattingParams>({ mode: 'solid', tolerance: 25, feather: 1, bg: 'transparent' })
const BGS: Array<{ key: MattingBg, label: string }> = [
{ key: 'transparent', label: '透明' },
{ key: 'white', label: '白' },
{ key: 'blue', label: '蓝' },
{ key: 'red', label: '红' },
]
const isH5 = ref(false)
// #ifdef H5
isH5.value = true
// #endif
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const comparing = ref(false)
const savedName = ref('')
const basePreview = ref('') // 透明小图(背景切换的合成源)
const previewPath = ref('') // 实际展示(含背景)
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
function onToleranceSlider(e: { detail: { value: number } }) {
tolerance.value = e.detail.value
onToleranceChange()
}
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
function loadParams() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
await preview()
const raw = uni.getStorageSync(PARAM_KEY)
if (!raw)
return
const s = typeof raw === 'string' ? JSON.parse(raw) : raw
if (typeof s.tolerance === 'number')
P.tolerance = Math.max(5, Math.min(100, Math.round(s.tolerance)))
if (typeof s.feather === 'number')
P.feather = Math.max(0, Math.min(6, Math.round(s.feather)))
if (s.bg === 'transparent' || s.bg === 'white' || s.bg === 'blue' || s.bg === 'red')
P.bg = s.bg
}
catch {
/* 取消 */
/* 忽略:脏数据走默认值 */
}
}
/** 预览:先降采样到 ≤1024 再抠图,调参实时反馈 */
async function preview() {
if (!src.value || busy.value)
return
busy.value = true
function saveParams() {
try {
const small = await resizeImage(src.value, { mode: 'maxEdge', value: 1024 })
const r = await removeBackgroundSolid(small.path, tolerance.value)
result.value = r
uni.setStorageSync(PARAM_KEY, JSON.stringify({ tolerance: P.tolerance, feather: P.feather, bg: P.bg }))
}
catch (e) {
uni.showToast({ title: (e as Error).message || '处理失败', icon: 'none' })
}
finally {
busy.value = false
catch {
/* 忽略:存储失败不影响使用 */
}
}
loadParams()
watch(() => [P.tolerance, P.feather, P.bg], saveParams)
function onToleranceChange() {
const tool = useImageTool<{ path: string }, MattingParams>({
params: P,
autoRun: true,
run: async (srcPath, p) => {
// 预览先降采样到 ≤1024,调参实时反馈
const small = await resizeImage(srcPath, { mode: 'maxEdge', value: PREVIEW_MAX })
const r = p.mode === 'smart'
? await removeBackgroundSmart(small.path)
: await removeBackgroundSolid(small.path, { tolerance: p.tolerance, feather: p.feather })
return { path: r.path }
},
})
const { src, running, error, exec, pickAndRun, rerun } = tool
const shownPath = computed(() => (comparing.value ? src.value : previewPath.value))
watch(() => tool.result.value, async (r) => {
basePreview.value = r?.path || ''
await recomputePreview()
})
/** 按当前背景从透明小图合成预览(背景切换无需重新抠图) */
async function recomputePreview() {
if (!basePreview.value) {
previewPath.value = ''
return
}
if (P.bg === 'transparent') {
previewPath.value = basePreview.value
return
}
const c = await applyBackground(basePreview.value, P.bg)
previewPath.value = c.path
}
function chooseBg(b: MattingBg) {
if (P.bg === b)
return
P.bg = b
recomputePreview()
}
function chooseMode(m: Mode) {
if (m === 'smart' && P.mode !== 'smart') {
if (!isH5.value) {
uni.showModal({
title: '智能抠图',
content: '智能抠图当前仅网页版可用,小程序端请使用纯色模式。',
showCancel: false,
confirmText: '知道了',
})
return
}
if (!SMART_MODEL_URL) {
uni.showModal({
title: '能力暂未开放',
content: '智能抠图正在接入中,当前请使用纯色模式。',
showCancel: false,
confirmText: '知道了',
})
return
}
}
if (P.mode === m)
return
P.mode = m
if (src.value)
rerun()
}
let timer: ReturnType<typeof setTimeout> | null = null
function scheduleRerun() {
if (!src.value)
return
if (debounceTimer)
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => preview(), 350)
if (timer)
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
rerun()
}, 350)
}
function onTolerance(v: number) {
P.tolerance = v
scheduleRerun()
}
function onFeather(v: number) {
P.feather = v
scheduleRerun()
}
async function rePick() {
savedName.value = ''
comparing.value = false
await pickAndRun()
}
async function save() {
if (!src.value)
if (!src.value) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return
const r = await runTask('全尺寸处理中', () => removeBackgroundSolid(src.value, tolerance.value))
if (r)
await saveResult(r.path, r.name)
}
async function saveSmart() {
const r = await runTask('AI 抠图中', () => removeBackgroundSmart(src.value))
if (r) {
result.value = r
await saveResult(r.path, r.name)
}
// 全尺寸抠图 + 背景合成(复用 running 守卫,防重复点击)
const produced = await exec(async () => {
const r = P.mode === 'smart'
? await removeBackgroundSmart(src.value)
: await removeBackgroundSolid(src.value, { tolerance: P.tolerance, feather: P.feather })
return applyBackground(r.path, P.bg)
})
if (!produced)
return
const res = await saveResult(produced.path, produced.name, { silent: true })
if (res.ok) {
savedName.value = res.savedName
// 单次反馈(silent 保存 + 自弹一条,避免双 toast)
uni.showToast({
title: `${isH5.value ? '已开始下载' : '已保存到相册'}:${res.savedName}`,
icon: 'none',
})
}
else if (res.reason === 'NO_ALBUM_AUTH') {
uni.showModal({
title: '需要相册权限',
content: '请在设置中允许保存到相册',
confirmText: '去设置',
success: (r) => {
if (r.confirm)
uni.openSetting({})
},
})
}
else {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
</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>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片(纯色背景效果最佳)
</text>
</view>
<ToolPage title="去除背景" subtitle="纯色一键抠图 · 换背景导出" :need-canvas="true">
<ToolIdle
v-if="!src"
text="选择图片"
hint="纯色背景效果最佳:白墙、纯色布、纯色桌面"
icon="image"
@pick="pickAndRun"
/>
<template v-else>
<view class="preview checker-bg" @tap="pick">
<image :src="result?.path || src" mode="aspectFit" class="preview__img" />
<!-- 模式分段:小程序端也展示「智能模式」入口 -->
<view class="seg">
<view
class="seg__item"
:class="{ 'seg__item--on': P.mode === 'solid' }"
@tap="chooseMode('solid')"
>
纯色模式
</view>
<view
class="seg__item"
:class="{ 'seg__item--on': P.mode === 'smart' }"
@tap="chooseMode('smart')"
>
智能模式
</view>
</view>
<view class="preview" :class="{ 'checker-bg': P.bg === 'transparent' }" @tap="rePick">
<image v-if="shownPath" :src="shownPath" mode="aspectFit" class="preview__img" />
<view class="preview__badge">
<text>{{ busy ? '处理中…' : '点击重新选择' }}</text>
<text>{{ running ? '处理中…' : (comparing ? '原图 · 点击切换' : '结果 · 点击重新选择') }}</text>
</view>
</view>
<view v-if="src && previewPath" class="compare">
<view class="compare__btn press" hover-class="press-active" @tap="comparing = !comparing">
{{ comparing ? '看结果' : '对比原图' }}
</view>
</view>
<view class="card">
<view class="slider-row">
<text class="slider-label">
容差 {{ tolerance }}
</text>
<slider class="slider" :value="tolerance" :min="5" :max="100" active-color="#ff6b35" background-color="#ffe3d3" @change="onToleranceSlider" />
</view>
<ToolSliderRow
:label="`容差 ${P.tolerance}`"
:value="P.tolerance"
:min="5"
:max="100"
label-width="190rpx"
@change="onTolerance"
/>
<ToolSliderRow
:label="`羽化强度 ${P.feather}`"
:value="P.feather"
:min="0"
:max="6"
:step="1"
label-width="190rpx"
@change="onFeather"
/>
<text class="tip">
拖动容差让背景去除更干净;边缘发虚请调小。确认后保存时会按原图尺寸重新处理。
背景去除不干净:调大容差;边缘发虚:调小容差并加大羽化。保存时会按原图尺寸重新处理。
</text>
</view>
<wd-button type="primary" block :loading="busy" @click="preview">
重新预览
</wd-button>
<wd-button type="primary" block @click="save">
保存高清结果
</wd-button>
<wd-button v-if="isH5" block plain @click="saveSmart">
智能抠图(AI · 网页版)
</wd-button>
<view class="card">
<text class="card__title">
背景
</text>
<view class="bgs">
<view
v-for="b in BGS"
:key="b.key"
class="bg press"
:class="{ 'bg--on': P.bg === b.key, 'checker-bg': b.key === 'transparent' }"
:style="b.key === 'white' ? { background: '#ffffff' } : b.key === 'blue' ? { background: '#438EDB' } : b.key === 'red' ? { background: '#E23B2E' } : {}"
hover-class="press-active"
@tap="chooseBg(b.key)"
>
<text class="bg__label" :class="{ 'bg__label--light': b.key === 'blue' || b.key === 'red' }">
{{ b.label }}
</text>
</view>
</view>
</view>
<view class="actions">
<view class="btn press" :class="{ 'btn--busy': running }" hover-class="press-active" @tap="save">
{{ running ? '处理中…' : '保存结果' }}
</view>
</view>
<view v-if="savedName" class="saved">
<text class="saved__text">
已保存:{{ savedName }}
</text>
</view>
<view v-if="error" class="notice">
<text class="notice__text">
{{ error }}
</text>
</view>
<view class="hint">
<text class="hint__text">
抠不干净?换个纯色背景重拍,或试试智能模式。
</text>
</view>
</template>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
<ToolRunning :show="running" :text="P.mode === 'smart' ? '智能抠图中…' : '去除背景中…'" />
<VipDock />
</view>
</ToolPage>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
.seg {
display: flex;
flex-direction: column;
gap: 28rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
gap: 14rpx;
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;
border: 2rpx solid #ffe3d3;
border-radius: 999rpx;
padding: 8rpx;
}
.nav__title {
.seg__item {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
padding: 18rpx 0;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
color: #9a7b65;
padding: 0 40rpx;
text-align: center;
transition: all 0.2s ease;
}
.seg__item--on {
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
color: #ffffff;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.28);
}
.preview {
position: relative;
@@ -219,6 +385,20 @@ async function saveSmart() {
border-radius: 999rpx;
padding: 8rpx 20rpx;
}
.compare {
display: flex;
justify-content: center;
margin-top: -12rpx;
}
.compare__btn {
font-size: 24rpx;
font-weight: 700;
color: #ff6b35;
background: #fff3ec;
border: 2rpx solid #ffd4b8;
border-radius: 999rpx;
padding: 12rpx 32rpx;
}
.card {
background: #ffffff;
border: 2rpx solid #ffe3d3;
@@ -228,20 +408,85 @@ async function saveSmart() {
flex-direction: column;
gap: 20rpx;
}
.slider-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.slider-label {
flex-shrink: 0;
width: 150rpx;
font-size: 24rpx;
color: #9a7b65;
.card__title {
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
}
/* 滑块行样式由 ToolSliderRow 组件自带(原 .slider-row / .slider-label / .slider 已移除) */
.tip {
font-size: 22rpx;
color: #9a7b65;
line-height: 1.6;
}
.bgs {
display: flex;
gap: 16rpx;
}
.bg {
flex: 1;
height: 88rpx;
border-radius: 20rpx;
border: 2rpx solid #ffe3d3;
display: flex;
align-items: center;
justify-content: center;
}
.bg--on {
border-color: #ff6b35;
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.24);
}
.bg__label {
font-size: 24rpx;
font-weight: 700;
color: #3d2a1e;
}
.bg__label--light {
color: #ffffff;
}
.actions {
margin-top: 4rpx;
}
.btn {
text-align: center;
padding: 28rpx 0;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
color: #ffffff;
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
box-shadow: 0 12rpx 26rpx rgba(251, 78, 20, 0.3);
}
.btn--busy {
opacity: 0.7;
}
.saved {
background: #f0fdfa;
border: 2rpx solid #b8ece1;
border-radius: 20rpx;
padding: 18rpx 24rpx;
}
.saved__text {
font-size: 24rpx;
color: #0d9488;
word-break: break-all;
}
.notice {
background: #fff3ec;
border: 2rpx solid #ffd4b8;
border-radius: 20rpx;
padding: 18rpx 24rpx;
}
.notice__text {
font-size: 24rpx;
color: #b5501e;
}
.hint {
padding: 0 8rpx;
}
.hint__text {
font-size: 22rpx;
color: #9a7b65;
line-height: 1.6;
}
</style>

View File

@@ -1,20 +1,68 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
/**
* 二维码生成(T04)
*
* 布局走 `ToolPage`,流程走 `useImageTool`(onReady 绑画布 + running 防双击)。
* 类型:文本 / 网址 / WiFi / 名片;输入即实时预览(350ms 防抖);空内容走占位、不报错。
* 高级项:中心 Logo(自选或默认)、纠错级别(M/H)、输出尺寸。
* 命名走 `contentDigestName` → `二维码生成-{内容摘要}-{时间戳}.png`(PRD-01 R5-9)。
* 保存为 `silent: true` + 自弹一条带文件名的反馈,避免双 toast。
*/
import { reactive, ref, watch } from 'vue'
import ToolPage from '@/components/tool/ToolPage.vue'
import ToolRunning from '@/components/tool/ToolRunning.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { beginDraw, bindImageCanvas, exportFile, saveResult } from '@/utils/image/core'
import { useImageTool } from '@/composables/useImageTool'
import { beginDraw, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
import { contentDigestName } from '@/utils/image/naming'
import { makeMatrix } from '@/utils/image/qrcode'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const SIZE = 720
const navPad = navPadTop()
const text = ref('')
const fg = ref('#3D2A1E')
const bg = ref('#FFFFFF')
const previewPath = ref('')
const generating = ref(false)
type QrType = 'text' | 'url' | 'wifi' | 'vcard'
type QrEcc = 'M' | 'H'
interface QrParams {
type: QrType
text: string
wifiSsid: string
wifiPwd: string
wifiEnc: 'WPA' | 'WEP' | 'nopass'
vcName: string
vcTel: string
vcOrg: string
ecc: QrEcc
size: number
logoOn: boolean
fg: string
bg: string
}
const P = reactive<QrParams>({
type: 'text',
text: '',
wifiSsid: '',
wifiPwd: '',
wifiEnc: 'WPA',
vcName: '',
vcTel: '',
vcOrg: '',
ecc: 'M',
size: 720,
logoOn: false,
fg: '#3D2A1E',
bg: '#FFFFFF',
})
const TYPES: Array<{ key: QrType, label: string }> = [
{ key: 'text', label: '文本' },
{ key: 'url', label: '网址' },
{ key: 'wifi', label: 'WiFi' },
{ key: 'vcard', label: '名片' },
]
const ECCS: Array<{ key: QrEcc, label: string }> = [
{ key: 'M', label: 'M · 标准' },
{ key: 'H', label: 'H · 强容错' },
]
const SIZES = [480, 720, 1080]
const PALETTES = [
{ fg: '#3D2A1E', bg: '#FFFFFF', name: '暖炭' },
{ fg: '#FF6B35', bg: '#FFF9F2', name: '元气' },
@@ -22,29 +70,96 @@ const PALETTES = [
{ fg: '#111111', bg: '#FFFFFF', name: '经典' },
]
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
const isH5 = ref(false)
// #ifdef H5
isH5.value = true
// #endif
async function generate() {
const content = text.value.trim()
if (!content) {
uni.showToast({ title: '请输入文本或链接', icon: 'none' })
const previewPath = ref('')
const notice = ref('')
const savedName = ref('')
const logoPath = ref('')
/** 按当前类型拼装二维码承载内容(空内容 → 空串,走占位不报错) */
function buildPayload(p: QrParams): string {
switch (p.type) {
case 'text':
return p.text.trim()
case 'url': {
const t = p.text.trim()
if (!t)
return ''
// 缺协议时自动补 https://(输入框仍可编辑)
return /^[a-z][a-z0-9+.-]*:\/\//i.test(t) ? t : `https://${t}`
}
case 'wifi': {
const ssid = p.wifiSsid.trim()
if (!ssid)
return ''
const pwd = p.wifiEnc === 'nopass' ? '' : p.wifiPwd
return `WIFI:T:${p.wifiEnc};S:${ssid};P:${pwd};;`
}
case 'vcard': {
const name = p.vcName.trim()
const tel = p.vcTel.trim()
if (!name && !tel)
return ''
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
if (name)
lines.push(`FN:${name}`, `N:${name};;;;`)
if (p.vcOrg.trim())
lines.push(`ORG:${p.vcOrg.trim()}`)
if (tel)
lines.push(`TEL;TYPE=CELL:${tel}`)
lines.push('END:VCARD')
return lines.join('\n')
}
default:
return ''
}
}
/** 画中心 Logo:清白底(H 级纠错可承受中心遮挡)→ 自选图 / 默认橙底「码」 */
async function drawLogo(ctx: CanvasRenderingContext2D, size: number) {
const box = Math.round(size * 0.24)
const x = (size - box) / 2
const y = (size - box) / 2
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(x, y, box, box)
if (logoPath.value) {
const limg = await loadImage(logoPath.value)
ctx.drawImage(limg.img as CanvasImageSource, x, y, box, box)
return
}
generating.value = true
const r = await runTask('生成中…', async () => {
const { count, isDark } = makeMatrix(content)
ctx.fillStyle = '#ff6b35'
ctx.fillRect(x, y, box, box)
ctx.fillStyle = '#FFFFFF'
try {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold ${Math.round(box * 0.56)}px sans-serif`
}
catch {
/* 个别端不支持 font:退回默认字体,不影响可扫 */
}
ctx.fillText('码', size / 2, size / 2)
}
const tool = useImageTool<{ path: string }, QrParams>({
params: P,
requireSrc: false,
autoRun: false,
run: async (_src, p) => {
const payload = buildPayload(p)
const { count, isDark } = makeMatrix(payload, p.ecc)
const quiet = 2 // 四周静区(模块数)
const total = count + quiet * 2
const cell = SIZE / total
const { ctx } = beginDraw(SIZE, SIZE)
const cell = p.size / total
const { ctx } = beginDraw(p.size, p.size)
ctx.save()
ctx.fillStyle = bg.value
ctx.fillRect(0, 0, SIZE, SIZE)
ctx.fillStyle = fg.value
ctx.fillStyle = p.bg
ctx.fillRect(0, 0, p.size, p.size)
ctx.fillStyle = p.fg
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (isDark(row, col))
@@ -52,52 +167,170 @@ async function generate() {
}
}
ctx.restore()
return exportFile('png', 1)
})
if (p.logoOn)
await drawLogo(ctx, p.size)
return { path: await exportFile('png', 1) }
},
})
const { running } = tool
/** 实时预览:容量预检 → 超长直接提示(不产空白码)→ 生成 */
async function refresh() {
const payload = buildPayload(P)
if (!payload) {
previewPath.value = ''
notice.value = ''
return
}
try {
makeMatrix(payload, P.ecc)
}
catch {
previewPath.value = ''
notice.value = '内容过长,建议缩短或改用短链'
return
}
notice.value = ''
const r = await tool.doRun()
if (r)
previewPath.value = r
generating.value = false
previewPath.value = r.path
}
let timer: ReturnType<typeof setTimeout> | null = null
function schedulePreview() {
if (timer)
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
refresh()
}, 350)
}
watch(
() => [P.type, P.text, P.wifiSsid, P.wifiPwd, P.wifiEnc, P.vcName, P.vcTel, P.vcOrg, P.ecc, P.size, P.logoOn, P.fg, P.bg],
schedulePreview,
)
function chooseType(t: QrType) {
if (P.type === t)
return
P.type = t
savedName.value = ''
schedulePreview()
}
async function pickLogo() {
try {
const p = await chooseOneImage()
logoPath.value = p.path
P.logoOn = true
// 中心镂空需强容错,自动提升到 H
P.ecc = 'H'
schedulePreview()
}
catch {
/* 取消 */
}
}
async function save() {
if (!previewPath.value)
return
await runTask('保存中…', async () => {
await saveResult(previewPath.value, 'qrcode.png')
return previewPath.value
})
}
function goBack() {
uni.navigateBack()
const payload = buildPayload(P)
const name = contentDigestName(payload, 'png')
const res = await saveResult(previewPath.value, name, { silent: true })
if (res.ok) {
savedName.value = res.savedName
uni.showToast({
title: `${isH5.value ? '已开始下载' : '已保存到相册'}:${res.savedName}`,
icon: 'none',
})
}
else if (res.reason === 'NO_ALBUM_AUTH') {
uni.showModal({
title: '需要相册权限',
content: '请在设置中允许保存到相册',
confirmText: '去设置',
success: (r) => {
if (r.confirm)
uni.openSetting({})
},
})
}
else {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
</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" />
<ToolPage title="二维码生成" subtitle="文本 / 网址 / WiFi / 名片" :need-canvas="true">
<!-- 类型分段 -->
<view class="seg">
<view
v-for="t in TYPES"
:key="t.key"
class="seg__item"
:class="{ 'seg__item--on': P.type === t.key }"
@tap="chooseType(t.key)"
>
{{ t.label }}
</view>
<text class="nav__title">
二维码生成
</text>
<view class="nav__slot" />
</view>
<!-- 内容输入 -->
<view class="card">
<text class="card__title">
内容
</text>
<textarea
v-model="text"
class="content"
placeholder="输入文本、网址、Wi-Fi 信息…"
placeholder-style="color:#C9AE9C"
:maxlength="500"
/>
<template v-if="P.type === 'text' || P.type === 'url'">
<textarea
v-if="P.type === 'text'"
v-model="P.text"
class="content"
placeholder="输入文本…"
placeholder-style="color:#C9AE9C"
:maxlength="500"
/>
<input
v-else
v-model="P.text"
class="input"
placeholder="example.com 或 https://example.com"
placeholder-style="color:#C9AE9C"
:maxlength="500"
>
<text v-if="P.type === 'url'" class="field-tip">
未填协议时会自动补 https://,可手动编辑。
</text>
</template>
<template v-else-if="P.type === 'wifi'">
<input v-model="P.wifiSsid" class="input" placeholder="WiFi 名称(SSID)" placeholder-style="color:#C9AE9C" :maxlength="64">
<input v-model="P.wifiPwd" class="input" placeholder="密码" placeholder-style="color:#C9AE9C" :maxlength="64">
<view class="chips">
<view
v-for="e in [{ k: 'WPA', l: 'WPA/WPA2' }, { k: 'WEP', l: 'WEP' }, { k: 'nopass', l: '无密码' }]"
:key="e.k"
class="chip"
:class="{ 'chip--on': P.wifiEnc === e.k }"
@tap="P.wifiEnc = e.k as QrParams['wifiEnc']"
>
{{ e.l }}
</view>
</view>
</template>
<template v-else>
<input v-model="P.vcName" class="input" placeholder="姓名" placeholder-style="color:#C9AE9C" :maxlength="40">
<input v-model="P.vcTel" class="input" placeholder="电话" placeholder-style="color:#C9AE9C" :type="'text'" :maxlength="40">
<input v-model="P.vcOrg" class="input" placeholder="公司 / 组织(可选)" placeholder-style="color:#C9AE9C" :maxlength="40">
</template>
</view>
<!-- 配色 -->
<view class="card">
<text class="card__title">
配色
@@ -107,9 +340,9 @@ function goBack() {
v-for="p in PALETTES"
:key="p.name"
class="palette press"
:class="{ 'palette--on': fg === p.fg && bg === p.bg }"
:class="{ 'palette--on': P.fg === p.fg && P.bg === p.bg }"
hover-class="press-active"
@tap="((fg = p.fg), (bg = p.bg))"
@tap="((P.fg = p.fg), (P.bg = p.bg))"
>
<view class="palette__dot" :style="{ background: p.fg }" />
<text>{{ p.name }}</text>
@@ -117,76 +350,130 @@ function goBack() {
</view>
</view>
<view class="actions">
<wd-button type="primary" block :loading="generating" @click="generate">
生成二维码
</wd-button>
<!-- 高级 -->
<view class="card">
<text class="card__title">
高级
</text>
<view class="row">
<text class="row__label">
中心 Logo
</text>
<view class="row__ctl">
<view class="mini press" :class="{ 'mini--on': !P.logoOn }" hover-class="press-active" @tap="P.logoOn = false">
关闭
</view>
<view class="mini press" :class="{ 'mini--on': P.logoOn }" hover-class="press-active" @tap="P.logoOn = true">
开启
</view>
<view class="mini press" hover-class="press-active" @tap="pickLogo">
选图
</view>
</view>
</view>
<view class="row">
<text class="row__label">
纠错级别
</text>
<view class="row__ctl">
<view
v-for="e in ECCS"
:key="e.key"
class="mini press"
:class="{ 'mini--on': P.ecc === e.key }"
hover-class="press-active"
@tap="P.ecc = e.key"
>
{{ e.label }}
</view>
</view>
</view>
<view class="row">
<text class="row__label">
输出尺寸
</text>
<view class="row__ctl">
<view
v-for="s in SIZES"
:key="s"
class="mini press"
:class="{ 'mini--on': P.size === s }"
hover-class="press-active"
@tap="P.size = s"
>
{{ s }}px
</view>
</view>
</view>
<text class="tip">
中心加 Logo 会遮挡部分码点,建议纠错级别选 H 以保证可扫。
</text>
</view>
<view v-if="previewPath" class="result">
<image :src="previewPath" mode="aspectFit" class="result__img" />
<wd-button type="primary" block custom-style="border-radius: 999rpx;" @click="save">
保存到相册
</wd-button>
<!-- 预览 -->
<view class="result">
<image v-if="previewPath" :src="previewPath" mode="aspectFit" class="result__img" />
<view v-else class="result__ph">
<text class="result__ph-text">
{{ notice || '输入内容后自动生成预览' }}
</text>
</view>
<view v-if="notice && previewPath" class="notice">
<text class="notice__text">
{{ notice }}
</text>
</view>
<view class="sbtn press" :class="{ 'sbtn--busy': running || !previewPath }" hover-class="press-active" @tap="save">
{{ running ? '生成中…' : '保存到相册' }}
</view>
<text v-if="savedName" class="saved">
已保存:{{ savedName }}
</text>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
<ToolRunning :show="running" text="生成二维码…" />
<VipDock />
</view>
</ToolPage>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
.seg {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
gap: 14rpx;
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;
border: 2rpx solid #ffe3d3;
border-radius: 999rpx;
padding: 8rpx;
}
.nav__title {
font-size: 34rpx;
.seg__item {
flex: 1;
text-align: center;
padding: 18rpx 0;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
color: #9a7b65;
transition: all 0.2s ease;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
.seg__item--on {
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
color: #ffffff;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.28);
}
.card {
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
padding: 26rpx 28rpx;
margin-top: 24rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 18rpx;
}
.card__title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
margin-bottom: 18rpx;
}
.content {
width: 100%;
@@ -199,6 +486,38 @@ function goBack() {
font-size: 28rpx;
color: #3d2a1e;
}
.input {
width: 100%;
box-sizing: border-box;
height: 84rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.field-tip {
font-size: 22rpx;
color: #9a7b65;
}
.chips {
display: flex;
gap: 14rpx;
}
.chip {
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
font-size: 24rpx;
color: #3d2a1e;
}
.chip--on {
border-color: #ff6b35;
color: #ff6b35;
font-weight: 700;
}
.palettes {
display: flex;
gap: 16rpx;
@@ -224,26 +543,106 @@ function goBack() {
height: 24rpx;
border-radius: 50%;
}
.actions {
margin-top: 28rpx;
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.row__label {
flex-shrink: 0;
font-size: 24rpx;
color: #9a7b65;
}
.row__ctl {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
justify-content: flex-end;
}
.mini {
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
font-size: 22rpx;
color: #3d2a1e;
}
.mini--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
font-weight: 700;
}
.tip {
font-size: 22rpx;
color: #9a7b65;
line-height: 1.6;
}
.result {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
margin-top: 32rpx;
gap: 22rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.result__img {
width: 440rpx;
height: 440rpx;
border-radius: 20rpx;
background: #fff9f2;
}
.result__ph {
width: 440rpx;
height: 440rpx;
border-radius: 20rpx;
background: #fff9f2;
border: 2rpx dashed #ffd4b8;
display: flex;
align-items: center;
justify-content: center;
padding: 0 30rpx;
box-sizing: border-box;
}
.result__ph-text {
font-size: 24rpx;
color: #b3907c;
text-align: center;
line-height: 1.6;
}
.sbtn {
width: 100%;
text-align: center;
padding: 26rpx 0;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
color: #ffffff;
background: linear-gradient(135deg, #ff9f4d, #ff6b35);
box-shadow: 0 12rpx 26rpx rgba(251, 78, 20, 0.3);
}
.sbtn--busy {
opacity: 0.5;
}
.notice {
width: 100%;
background: #fff3ec;
border: 2rpx solid #ffd4b8;
border-radius: 20rpx;
padding: 16rpx 24rpx;
box-sizing: border-box;
}
.notice__text {
font-size: 24rpx;
color: #b5501e;
}
.saved {
font-size: 22rpx;
color: #0d9488;
word-break: break-all;
text-align: center;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import ToolSliderRow from '@/components/tool/ToolSliderRow.vue'
import VipDock from '@/components/vip/VipDock.vue'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
@@ -19,11 +20,11 @@ const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
function onOpacityChange(e: { detail: { value: number } }) {
opacity.value = e.detail.value
function onOpacityChange(v: number) {
opacity.value = v
}
function onAngleChange(e: { detail: { value: number } }) {
angle.value = e.detail.value
function onAngleChange(v: number) {
angle.value = v
}
onReady(async () => {
@@ -100,18 +101,17 @@ async function save() {
<text>居中</text>
</view>
</view>
<view class="slider-row">
<text class="slider-label">
透明度
</text>
<slider class="slider" :value="opacity" :min="5" :max="100" active-color="#ff6b35" background-color="#ffe3d3" @change="onOpacityChange" />
</view>
<view v-if="mode === 'tile'" class="slider-row">
<text class="slider-label">
角度 {{ angle }}°
</text>
<slider class="slider" :value="angle" :min="-90" :max="90" :step="5" active-color="#ff6b35" background-color="#ffe3d3" @change="onAngleChange" />
</view>
<ToolSliderRow label="透明度" :value="opacity" :min="5" :max="100" label-width="140rpx" @change="onOpacityChange" />
<ToolSliderRow
v-if="mode === 'tile'"
:label="`角度 ${angle}°`"
:value="angle"
:min="-90"
:max="90"
:step="5"
label-width="140rpx"
@change="onAngleChange"
/>
</view>
<wd-button type="primary" block @click="run">
@@ -229,15 +229,5 @@ async function save() {
border-color: #ff6b35;
color: #ffffff;
}
.slider-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.slider-label {
flex-shrink: 0;
width: 140rpx;
font-size: 24rpx;
color: #9a7b65;
}
/* 滑块行样式由 ToolSliderRow 组件自带(原 .slider-row / .slider-label 已移除) */
</style>

View File

@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { navPadTop } from '@/utils/safearea'
import { Z } from '@/utils/z-index'
const toolsStore = useToolsStore()
const userStore = useUserStore()
@@ -147,7 +148,7 @@ async function save() {
</view>
<!-- 底部悬浮保存栏 -->
<view class="save-bar">
<view class="save-bar" :style="{ zIndex: Z.stickyBar }">
<wd-button
type="primary"
block
@@ -273,6 +274,5 @@ async function save() {
left: 32rpx;
right: 32rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom, 0px));
z-index: 8; /* 低于 wot 弹层 */
}
</style>

245
src/pkg-event/list.vue Normal file
View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
/**
* 年会活动列表(分包 pkg-event,我的 → 年会查座 的入口页)。
*
* 起因:入口原先直连 `/pkg-event/query`,但 query 页要求 `event_id`(或小程序码 scene),
* 从「我的」点进来没有参数 → 落到「缺少活动参数」错误态,用户以为功能坏了。
*
* 现入口先落到本页:
* - 拉公开活动列表(GET /event/list,只有已发布活动);
* - 恰好 1 个活动 → 直接进入该活动查座页(首次进入自动跳一次);
* - 0 个活动 → 展示空态文案(**不是报错**);
* - 加载失败 → 可重试。
* 本页免登录(公开接口),不触发任何登录跳转。
*/
import type { EventListItem } from '@/api/event'
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { apiEventList } from '@/api/event'
import { heroPadTop } from '@/utils/safearea'
const heroPad = heroPadTop()
const loading = ref(true)
const list = ref<EventListItem[]>([])
const loadError = ref('')
/** 单活动自动进入只做一次(返回本页后不再反复跳转) */
let autoEntered = false
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await apiEventList()
list.value = res.list || []
// 只有一个活动:直接进入,省掉一次无意义的选择
if (list.value.length === 1 && !autoEntered) {
autoEntered = true
gotoQuery(list.value[0].event_id)
}
}
catch {
loadError.value = '活动列表加载失败,请检查网络后重试'
}
finally {
loading.value = false
}
}
onShow(load)
function gotoQuery(eventId: number) {
uni.navigateTo({ url: `/pkg-event/query?event_id=${eventId}` })
}
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>
<view class="nav__titles">
<text class="nav__title">年会查座</text>
<text class="nav__sub">选择要查询的活动</text>
</view>
</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="loadError" class="notice tb-card tb-elev-1">
<wd-icon name="warning" size="40rpx" color="#E5484D" />
<view class="notice__body">
<text class="notice__title">{{ loadError }}</text>
</view>
<view class="notice__retry press" hover-class="press-active" @tap="load">
重试
</view>
</view>
<view v-else-if="list.length" class="list">
<view
v-for="e in list"
:key="e.event_id"
class="ev tb-card tb-elev-2 press"
hover-class="press-active"
@tap="gotoQuery(e.event_id)"
>
<view class="ev__badge">
<wd-icon name="location" size="36rpx" color="#ffffff" />
</view>
<view class="ev__body">
<text class="ev__title">{{ e.title || '年会活动' }}</text>
<view v-if="e.event_time" class="ev__row">
<wd-icon name="clock" size="22rpx" color="#9A7B65" />
<text class="ev__text">{{ e.event_time }}</text>
</view>
<view v-if="e.venue" class="ev__row">
<wd-icon name="location" size="22rpx" color="#9A7B65" />
<text class="ev__text">{{ e.venue }}</text>
</view>
</view>
<wd-icon name="arrow-right" size="30rpx" color="#D9C3B2" />
</view>
</view>
<!-- 空态:不是报错 -->
<view v-else class="tb-empty">
<wd-icon name="warning" size="52rpx" color="#E8A87C" />
<text class="tb-empty__title">暂无可查询的活动</text>
<text class="tb-empty__sub">活动开放查询后,这里会出现入口;也可扫描现场二维码直接查座。</text>
</view>
<view class="foot" />
</view>
</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__titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.nav__title {
font-size: 36rpx;
font-weight: 800;
color: #ffffff;
}
.nav__sub {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.ev {
display: flex;
align-items: center;
gap: 22rpx;
padding: 28rpx 26rpx;
}
.ev__badge {
flex-shrink: 0;
width: 88rpx;
height: 88rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
}
.ev__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.ev__title {
font-size: 32rpx;
font-weight: 800;
color: #3d2a1e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ev__row {
display: flex;
align-items: center;
gap: 8rpx;
}
.ev__text {
font-size: 24rpx;
color: #9a7b65;
}
.notice {
display: flex;
align-items: center;
gap: 18rpx;
padding: 28rpx 26rpx;
}
.notice__body {
flex: 1;
min-width: 0;
}
.notice__title {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.notice__retry {
flex-shrink: 0;
padding: 10rpx 28rpx;
border-radius: 999rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
}
.foot {
height: 20rpx;
}
</style>

612
src/pkg-event/query.vue Normal file
View File

@@ -0,0 +1,612 @@
<script setup lang="ts">
/**
* 年会查座页(分包 pkg-event,扫码直达)。
*
* 入口参数(架构 §5.1 E1/E2、§8.3):
* - 小程序码:`page = "pkg-event/query"`(逐字符一致),`scene = 十进制字符串的 event_id`(如 "12",**不含任何个人信息**);
* - H5 / 开发调试:`?event_id=12` 普通参数。
* 两者都支持,优先取 `event_id`,回退解析 `scene`(含 URL 解码)。
*
* 交互(PRD-02 §5.4、R7-12~R7-24):
* - 顶部展示活动主题 / 时间 / 地点;
* - `can_query=false` → 直接显示「活动未开放查询」并**禁用表单**(判据⑤);
* - 姓名 + 手机号**两个都填**才允许提交(只填一个给更轻的前端提示,不发请求);
* - 成功:显示「桌号 / 桌名 — 第 N 号座位」+ 座位示意(`SeatMap`)。
* 后端 E2 给出 `table_x/table_y/hall_w/hall_h` 时,按**真实相对位置**在会场图上高亮本人桌;
* 坐标缺失(0,含老数据/后端未部署)时组件自动回退到「本人桌居中 + 舞台」的老示意。
* - 失败:`found=false` → 直接展示后端 `message`(统一模糊文案,防枚举);4005 → 「活动未开放查询」;
* 4006/4007 → **合并为同一句**「操作过于频繁,请稍后再试」;网络错误 → 可重试。
*
* 🔴 本页**免登录**:不触发任何登录跳转(公开接口 EventPub,预期不会 401)。
* 🔴 隐私红线:不推断 / 不展示任何他人身份;不请求别桌数据(后端根本不提供)。
*/
import type { EventInfo, SeatQueryResult } from '@/api/event'
import {
apiEventInfo,
apiEventSeatQuery,
CODE_EVENT_UNAVAILABLE,
CODE_SEAT_COOLING,
CODE_SEAT_RATE_LIMITED,
EVENT_UNAVAILABLE_MESSAGE,
SEAT_BUSY_MESSAGE,
} from '@/api/event'
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'
const heroPad = heroPadTop()
/** 返回上一页 */
function goBack() {
uni.navigateBack()
}
const eventId = ref(0)
const info = ref<EventInfo | null>(null)
const loadingInfo = ref(false)
/** 加载活动信息失败时的提示(4005 / 网络) */
const infoError = ref('')
/** 活动信息是否已加载成功(用于决定表单可用性) */
const infoLoaded = ref(false)
const name = ref('')
const phone = ref('')
const submitting = ref(false)
const result = ref<SeatQueryResult | null>(null)
/** 查询失败 / 异常提示 */
const queryError = ref('')
/** 失败是否可重试(网络类错误为 true) */
const queryRetryable = ref(false)
const seatMapRef = ref<{ render: () => void, resume: () => void, pause: () => void } | null>(null)
/** 是否可查询(活动已发布) */
const canQuery = computed(() => !!info.value?.can_query)
/** 头区标题 */
const title = computed(() => info.value?.title || '年会查座')
const venue = computed(() => info.value?.venue || '')
const eventTime = computed(() => info.value?.event_time || '')
/** 两个都填才允许提交(只填一个不发请求,仅前端轻提示) */
const canSubmit = computed(
() => canQuery.value && !!name.value.trim() && !!phone.value.trim() && !submitting.value,
)
/** 结果文案:桌号 / 桌名 — 第 N 号座位 */
const seatText = computed(() => {
const r = result.value
if (!r)
return ''
const table = r.table_name ? `${r.table_no} ${r.table_name}` : `${r.table_no}`
return `${table} · 第 ${r.seat_no} 号座位`
})
// ============================================================================
// 入口参数解析
// ============================================================================
/** 从页面参数解析 event_id:优先 `?event_id=`,回退小程序码 `scene`(十进制字符串) */
function pickEventId(options: Record<string, any> | undefined): number {
const raw = options?.event_id ?? options?.eventId
if (raw != null && `${raw}`.trim() !== '') {
const n = Number(`${raw}`.trim())
if (Number.isFinite(n) && n > 0)
return Math.floor(n)
}
let scene = options?.scene
if (scene != null && `${scene}`.trim() !== '') {
try {
scene = decodeURIComponent(`${scene}`)
}
catch {
scene = `${scene}`
}
const n = Number(`${scene}`.trim())
if (Number.isFinite(n) && n > 0)
return Math.floor(n)
}
return 0
}
// ============================================================================
// 数据加载
// ============================================================================
async function loadInfo() {
if (!eventId.value)
return
loadingInfo.value = true
infoError.value = ''
try {
info.value = await apiEventInfo(eventId.value)
infoLoaded.value = true
}
catch (e) {
const code = (e as { code?: number }).code
infoError.value = code === CODE_EVENT_UNAVAILABLE
? EVENT_UNAVAILABLE_MESSAGE
: '活动信息加载失败,请稍后重试'
}
finally {
loadingInfo.value = false
}
}
function onRetryInfo() {
loadInfo()
}
// ============================================================================
// 查询
// ============================================================================
function onSubmit() {
if (submitting.value)
return
if (!canQuery.value) {
uni.showToast({ title: EVENT_UNAVAILABLE_MESSAGE, icon: 'none' })
return
}
if (!name.value.trim() || !phone.value.trim()) {
uni.showToast({ title: '请填写姓名和手机号', icon: 'none' })
return
}
void doQuery()
}
async function doQuery() {
submitting.value = true
queryError.value = ''
queryRetryable.value = false
try {
const res = await apiEventSeatQuery(eventId.value, name.value.trim(), phone.value.trim())
if (res.found) {
result.value = res
queryError.value = ''
}
else {
// 后端 message 已是统一模糊文案(防枚举,R7-21),直接展示,不自行拼文案
result.value = null
queryError.value = res.message || '未查询到您的座位信息,请联系活动组织者确认是否已录入'
}
}
catch (e) {
result.value = null
const code = (e as { code?: number }).code
if (code === CODE_EVENT_UNAVAILABLE) {
queryError.value = EVENT_UNAVAILABLE_MESSAGE
}
else if (code === CODE_SEAT_RATE_LIMITED || code === CODE_SEAT_COOLING) {
// 4006 / 4007 合并为同一句文案
queryError.value = SEAT_BUSY_MESSAGE
}
else {
queryError.value = '网络异常,请检查网络后重试'
queryRetryable.value = true
}
}
finally {
submitting.value = false
}
}
function onRetryQuery() {
doQuery()
}
// ============================================================================
// 动画与生命周期
// ============================================================================
watch(result, async (v) => {
if (v && v.found) {
await nextTick()
seatMapRef.value?.resume()
}
})
onLoad((options) => {
eventId.value = pickEventId(options as Record<string, any> | undefined)
if (!eventId.value) {
// 无活动参数:**不再落到「缺少活动参数」错误态**——改为进入活动列表页,
// 由用户在列表里选(只有 1 个活动时列表页会自动直达)。老的小程序码链接仍带 scene,不受影响。
uni.redirectTo({ url: '/pkg-event/list' })
return
}
loadInfo()
})
onShow(() => {
seatMapRef.value?.resume()
})
onHide(() => {
seatMapRef.value?.pause()
})
onUnload(() => {
seatMapRef.value?.pause()
})
</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>
<view class="nav__titles">
<text class="nav__title">{{ title }}</text>
<text class="nav__sub">扫码查座位</text>
</view>
</view>
<view v-if="eventTime || venue" class="meta">
<view v-if="eventTime" class="meta__row">
<wd-icon name="clock" size="24rpx" color="rgba(255,255,255,0.9)" />
<text class="meta__text">{{ eventTime }}</text>
</view>
<view v-if="venue" class="meta__row">
<wd-icon name="location" size="24rpx" color="rgba(255,255,255,0.9)" />
<text class="meta__text">{{ venue }}</text>
</view>
</view>
</view>
<!-- 上浮内容区 -->
<view class="tb-body">
<!-- 活动信息加载失败 -->
<view v-if="infoError" class="notice notice--warn tb-card tb-elev-1">
<wd-icon name="warning" size="34rpx" color="#E5484D" />
<view class="notice__body">
<text class="notice__title">{{ infoError }}</text>
<text class="notice__sub">如需查询,请联系活动组织者确认。</text>
</view>
<view class="notice__retry press" hover-class="press-active" @tap="onRetryInfo">
重试
</view>
</view>
<template v-else>
<!-- 查询表单(紧凑 · 横向) -->
<view class="form tb-card tb-elev-2" :class="{ 'form--off': !canQuery }">
<view class="fields">
<input
v-model="name"
class="field"
placeholder="姓名"
placeholder-class="field__ph"
:disabled="!canQuery"
:maxlength="32"
confirm-type="next"
>
<view class="fields__sep" />
<input
v-model="phone"
class="field"
type="number"
placeholder="手机号"
placeholder-class="field__ph"
:disabled="!canQuery"
:maxlength="11"
confirm-type="search"
@confirm="onSubmit"
>
</view>
<wd-button
type="primary"
:loading="submitting"
:disabled="!canQuery"
custom-style="border-radius: 999rpx; height: 84rpx; font-size: 30rpx; margin-top: 20rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="onSubmit"
>
查询我的座位
</wd-button>
<view class="privacy">
<wd-icon name="lock-on" size="22rpx" color="#B3907C" />
<text class="privacy__text">仅用于查询您本人的座位,信息不会被公开</text>
</view>
</view>
<!-- 活动未开放查询 -->
<view v-if="loadingInfo" class="tb-empty">
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
<text class="tb-empty__title">正在加载活动信息…</text>
</view>
<view v-else-if="!canQuery" class="notice notice--off tb-card tb-elev-1">
<wd-icon name="close-circle" size="34rpx" color="#9A7B65" />
<view class="notice__body">
<text class="notice__title">{{ EVENT_UNAVAILABLE_MESSAGE }}</text>
<text class="notice__sub">该活动尚未开放查询,请稍后再来。</text>
</view>
</view>
<!-- 查询失败 -->
<view v-else-if="queryError" class="query-fail tb-card tb-elev-1">
<wd-icon name="warning" size="40rpx" color="#E5484D" />
<text class="query-fail__text">{{ queryError }}</text>
<view v-if="queryRetryable" class="query-fail__retry press" hover-class="press-active" @tap="onRetryQuery">
重试
</view>
</view>
<!-- 查询成功:本人桌座 + 示意 -->
<view v-else-if="result && result.found" class="result">
<view class="result__head tb-card tb-elev-3">
<view class="result__badge">
<text class="result__badge-text">{{ result.table_no || '—' }}</text>
</view>
<view class="result__info">
<text class="result__table">{{ seatText }}</text>
<text v-if="result.table_name" class="result__extra">桌名:{{ result.table_name }}</text>
</view>
</view>
<view class="result__map tb-card tb-elev-2">
<text class="result__map-title">本人所在桌示意</text>
<SeatMap
ref="seatMapRef"
:capacity="result.capacity"
:seats="result.seats"
:self-seat-no="result.seat_no"
:table-no="result.table_no"
:table-name="result.table_name"
:stage="result.stage"
:table-x="result.table_x"
:table-y="result.table_y"
:hall-w="result.hall_w"
:hall-h="result.hall_h"
/>
<text class="result__map-tip">★ 高亮处即您所在的桌子与座位</text>
</view>
</view>
</template>
<view class="foot" />
</view>
</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__titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.nav__title {
font-size: 36rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav__sub {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.meta {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
margin-top: 22rpx;
}
.meta__row {
display: flex;
align-items: center;
gap: 10rpx;
}
.meta__text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.92);
}
/* 表单(紧凑横向) */
.form {
padding: 26rpx;
}
.form--off {
opacity: 0.6;
}
.fields {
display: flex;
align-items: center;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 0 8rpx;
height: 84rpx;
}
.field {
flex: 1;
min-width: 0;
height: 84rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #3d2a1e;
box-sizing: border-box;
}
.fields__sep {
width: 2rpx;
height: 40rpx;
background: #ffe3d3;
flex-shrink: 0;
}
.field__ph {
color: #c39a80;
}
.privacy {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 18rpx;
}
.privacy__text {
font-size: 22rpx;
color: #9a7b65;
}
/* 提示块 */
.notice {
display: flex;
align-items: center;
gap: 18rpx;
padding: 28rpx 26rpx;
}
.notice--warn {
border-color: #ffd6d6;
}
.notice--off {
border-color: #ffd9c2;
}
.notice__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.notice__title {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.notice__sub {
font-size: 22rpx;
color: #9a7b65;
}
.notice__retry {
flex-shrink: 0;
padding: 10rpx 28rpx;
border-radius: 999rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
}
/* 查询失败 */
.query-fail {
display: flex;
align-items: center;
gap: 16rpx;
padding: 32rpx 26rpx;
}
.query-fail__text {
flex: 1;
font-size: 26rpx;
line-height: 1.5;
color: #3d2a1e;
}
.query-fail__retry {
flex-shrink: 0;
padding: 12rpx 30rpx;
border-radius: 999rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
}
/* 结果 */
.result {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.result__head {
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
}
.result__badge {
flex-shrink: 0;
min-width: 104rpx;
height: 104rpx;
padding: 0 18rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.32);
}
.result__badge-text {
font-size: 40rpx;
font-weight: 800;
color: #ffffff;
}
.result__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.result__table {
font-size: 32rpx;
font-weight: 800;
color: #3d2a1e;
}
.result__extra {
font-size: 24rpx;
color: #9a7b65;
}
.result__map {
padding: 26rpx 22rpx;
}
.result__map-title {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
}
.result__map-tip {
display: block;
margin-top: 16rpx;
text-align: center;
font-size: 22rpx;
color: #b3907c;
}
.foot {
height: 20rpx;
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
/**
* 我的优惠券(分包 pkg-member,会员中心 ↔ 本页 双向可达)。
*
* 分栏:可用 / 已用 / 已过期 / 全部(默认「可用」)。
* - 免单券(coupon_type=1):金色实心,「去使用」→ 跳会员中心并**预选该券**;
* - 展示券(coupon_type=2):描边留白,「查看详情」→ 弹层展示券规则(无「去使用/抵扣」字样)。
* 三种状态(未使用 / 已使用 / 已过期)在卡片上可辨别(见 CouponCard)。
*/
import type { CouponSummary, UserCoupon } from '@/api/coupon'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { COUPON_STATUS_EXPIRED, COUPON_STATUS_UNUSED, COUPON_STATUS_USED, apiCouponMy } from '@/api/coupon'
import CouponCard from '@/components/coupon/CouponCard.vue'
import { useUserStore } from '@/stores/user'
import { navPadTop } from '@/utils/safearea'
const userStore = useUserStore()
const navPad = navPadTop()
/** 分栏:status 为 undefined 表示「全部」 */
interface TabItem {
key: string
label: string
status?: number
}
const TABS: TabItem[] = [
{ key: 'usable', label: '可用', status: COUPON_STATUS_UNUSED },
{ key: 'used', label: '已用', status: COUPON_STATUS_USED },
{ key: 'expired', label: '已过期', status: COUPON_STATUS_EXPIRED },
{ key: 'all', label: '全部' },
]
const activeTab = ref('usable')
const loading = ref(true)
const list = ref<UserCoupon[]>([])
const summary = ref<CouponSummary | null>(null)
const activeStatus = computed(() => TABS.find(t => t.key === activeTab.value)?.status)
const summaryText = computed(() => {
const s = summary.value
if (!s)
return ''
return `可用 ${s.usable} · 免单券 ${s.freebie}`
})
async function load() {
loading.value = true
try {
await userStore.ensureLogin()
const res = await apiCouponMy(activeStatus.value)
list.value = res.list || []
summary.value = res.summary || null
}
catch {
/* 错误已由 request 层统一提示 */
}
finally {
loading.value = false
}
}
onShow(load)
onPullDownRefresh(async () => {
await load()
uni.stopPullDownRefresh()
})
function switchTab(key: string) {
if (key === activeTab.value)
return
activeTab.value = key
list.value = []
load()
}
function goBack() {
uni.navigateBack()
}
function goMember() {
uni.navigateTo({ url: '/pkg-member/member/index' })
}
/** 免单券「去使用」→ 会员中心并预选该券(建单后到详情页自动应用) */
function onUse(coupon: UserCoupon) {
uni.navigateTo({ url: `/pkg-member/member/index?user_coupon_id=${coupon.id}` })
}
/** 展示券 / 其它「查看详情」→ 只读弹层,不引导下单 */
function onDetail(coupon: UserCoupon) {
const lines = [
coupon.sub_title || '',
coupon.threshold_cents > 0 ? `使用门槛:满 ¥${(coupon.threshold_cents / 100).toFixed(2)}` : '使用门槛:无门槛',
coupon.expire_at ? `有效期至:${coupon.expire_at}` : '有效期:长期',
coupon.coupon_type === 1 ? '' : '本券为展示券,仅供查看,不能抵扣订单金额。',
].filter(Boolean)
uni.showModal({
title: coupon.title || '优惠券详情',
content: lines.join('\n'),
showCancel: false,
confirmText: '知道了',
})
}
</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__link press" hover-class="press-active" @tap="goMember">
<text>会员</text>
</view>
</view>
<!-- 概览 -->
<view v-if="summaryText" class="summary">
<wd-icon name="discount-filled" size="30rpx" color="#e8531a" />
<text class="summary__text">{{ summaryText }}</text>
</view>
<!-- 分栏 -->
<view class="tabs">
<view
v-for="t in TABS"
:key="t.key"
class="tabs__item press"
:class="{ 'tabs__item--on': t.key === activeTab }"
hover-class="press-active"
@tap="switchTab(t.key)"
>
{{ t.label }}
</view>
</view>
<!-- 列表 -->
<view v-if="list.length" class="list">
<CouponCard
v-for="c in list"
:key="c.id"
:coupon="c"
@use="onUse"
@detail="onDetail"
/>
</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="goMember">
<text>去会员中心看看</text>
</view>
</view>
<view v-else class="loading">
正在加载…
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 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__link {
flex-shrink: 0;
min-width: 64rpx;
height: 64rpx;
border-radius: 999rpx;
background: #fff4d6;
border: 2rpx solid #ffe3a8;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #b57f00;
}
.summary {
display: flex;
align-items: center;
gap: 12rpx;
padding: 22rpx 26rpx;
border-radius: 24rpx;
background: linear-gradient(140deg, #fff9ef, #fdf0d8);
border: 2rpx solid rgba(224, 166, 63, 0.4);
}
.summary__text {
font-size: 25rpx;
font-weight: 700;
color: #3d2a1e;
}
.tabs {
display: flex;
gap: 12rpx;
margin: 24rpx 0 22rpx;
}
.tabs__item {
flex: 1;
text-align: center;
padding: 16rpx 0;
border-radius: 999rpx;
font-size: 26rpx;
color: #9a7b65;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
}
.tabs__item--on {
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
border-color: transparent;
font-weight: 700;
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.26);
}
.list {
padding-top: 4rpx;
}
.empty-wrap {
padding-top: 100rpx;
}
.empty-btn {
margin-top: 40rpx;
text-align: center;
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
border-radius: 999rpx;
padding: 24rpx 0;
font-size: 28rpx;
font-weight: 700;
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
}
.loading {
text-align: center;
padding: 80rpx 0;
font-size: 25rpx;
color: #9a7b65;
}
</style>

View File

@@ -0,0 +1,891 @@
<script setup lang="ts">
/**
* 订单详情(N1 两步式的「第二步」入口)。
*
* 职责:
* - 展示订单商品 / 三行金额(原价 / 已优惠 / 应付,未优惠时「已优惠」整行隐藏);
* - 应用优惠码(PromoCodeInput)/ 选择免单券(与码互斥,替换式);
* - 「去支付」→ 构建签名拉起收银台 → 成功后刷新等级/额度;
* - 已关闭订单 → 提示「已关闭,请重新下单」;4015/4011 文案可读。
*
* 入参(onLoad):out_trade_no(必填);order_type / target_key(选填,用于查询可选免单券)。
*/
import type { MemberOrderDetail } from '@/api/member'
import type { UsableCoupon } from '@/api/coupon'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import {
apiMemberOrderApplyVoucher,
apiMemberOrderDetail,
PROMO_KIND_CODE,
PROMO_KIND_COUPON,
} from '@/api/member'
import { apiCouponUsable } from '@/api/coupon'
import PromoCodeInput from '@/components/member/PromoCodeInput.vue'
import { useQuotaStore } from '@/stores/quota'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { fenToYuan, payMemberOrder } from '@/utils/member-pay'
import { payQuotaOrder } from '@/utils/quota-pay'
import { navPadTop } from '@/utils/safearea'
import { Z } from '@/utils/z-index'
const userStore = useUserStore()
const toolsStore = useToolsStore()
const quotaStore = useQuotaStore()
const navPad = navPadTop()
const outTradeNo = ref('')
const orderType = ref(0)
const targetKey = ref('')
/** 从来源页带过来的免单券 id(会员中心 / 优惠券页「去使用」);自动选券时优先使用 */
const prefillCouponId = ref(0)
/**
* 自动选券守卫:本页只自动选一次,且**用户手动清过优惠后不再回填**。
* 在 await 之前置位(见 autoSelectCoupon),否则 onPickCoupon→load 会递归。
*/
let autoSelectHandled = false
const loading = ref(true)
const order = ref<MemberOrderDetail | null>(null)
const applying = ref(false)
const paying = ref(false)
/** 可选免单券(仅免单券可抵扣,S7);含当前订单已选中的那张(后端以 applied 标记) */
const coupons = ref<UsableCoupon[]>([])
/** 优惠券选择抽屉是否打开 */
const couponDrawerVisible = ref(false)
/**
* 用户是否在本轮**显式选择过「不使用优惠券」**:用于入口文案在「未选券」时区分
* 「选择优惠券」(默认)与「不使用优惠券」(用户已主动放弃);切到某张券后复位。
*/
const couponNoneChosen = ref(false)
/** 状态:0 待支付 1 已发货 2 已退款 3 已关闭 */
const status = computed(() => order.value?.status ?? 0)
const isPending = computed(() => status.value === 0)
const isClosed = computed(() => status.value === 3)
const isDelivered = computed(() => status.value === 1)
const originFen = computed(() => order.value?.origin_price_cents ?? 0)
const paidFen = computed(() => order.value?.paid_price_cents ?? 0)
const discountFen = computed(() => order.value?.discount_cents ?? 0)
const showDiscount = computed(() => discountFen.value > 0)
const appliedCode = computed(() => (order.value?.promo_kind === PROMO_KIND_CODE ? order.value?.promo_code || '' : ''))
const appliedCouponId = computed(() => (order.value?.promo_kind === PROMO_KIND_COUPON ? order.value?.user_coupon_id || 0 : 0))
const hasVoucher = computed(() => !!appliedCode.value || appliedCouponId.value > 0)
const statusText = computed(() => order.value?.status_text || '')
/** 当前已选券的标题(入口行展示);未选 / 列表里查不到时回落到通用文案 */
const appliedCouponTitle = computed(() => {
if (!appliedCouponId.value)
return ''
return coupons.value.find(c => c.user_coupon_id === appliedCouponId.value)?.title || '已选优惠券'
})
/** 优惠券入口行文案:已选 → 券名;未选 → 「选择优惠券」(默认)/「不使用优惠券」(用户显式放弃过) */
const couponEntryText = computed(() => {
if (appliedCouponId.value)
return appliedCouponTitle.value
return couponNoneChosen.value ? '不使用优惠券' : '选择优惠券'
})
async function load() {
loading.value = true
try {
const d = await apiMemberOrderDetail(outTradeNo.value)
order.value = d
if (!orderType.value)
orderType.value = d.order_type
// 目标 key 一律以「详情响应」为准:order_type=1 → plan_key;order_type=2 → pack_key。
// 从响应推导可彻底杜绝来源页误把 tool_key 当 target_key(P0:次数包免单券链路);
// onLoad 的 query 仅作为响应缺列(历史订单)时的兜底,绝不覆盖响应值。
if (d.order_type === 2) {
if (d.pack_key)
targetKey.value = d.pack_key
}
else if (d.plan_key) {
targetKey.value = d.plan_key
}
await loadCoupons()
await autoSelectCoupon()
}
catch {
/* 错误已由 request 层统一提示(4015 文案可读) */
}
finally {
loading.value = false
}
}
async function loadCoupons() {
if (!isPending.value || !orderType.value)
return
try {
// 传 outTradeNo:后端会把「本单已选中(占用中、status 已非未使用)」的那张券保底返回,
// 否则自动选券后该券被 status 过滤掉 → 只有一张券时列表为空 → 用户看不到也换不了(P0)。
const res = await apiCouponUsable(orderType.value, targetKey.value, outTradeNo.value)
coupons.value = res.list || []
}
catch {
coupons.value = []
}
}
/** 打开优惠券选择抽屉;已使用优惠码时禁止(码 / 券互斥,保持既有语义与文案) */
function openCouponDrawer() {
if (!isPending.value)
return
if (appliedCode.value) {
uni.showToast({ title: '已使用优惠码,无法再选优惠券', icon: 'none' })
return
}
couponDrawerVisible.value = true
}
/** 应用优惠码(与选券互斥:后端会替换) */
async function onApplyCode(code: string) {
if (applying.value || !isPending.value)
return
applying.value = true
try {
const res = await apiMemberOrderApplyVoucher(outTradeNo.value, code, 0)
uni.showToast({ title: res.message || '优惠码已应用', icon: 'none' })
await load()
}
catch {
/* 4010/4011 等已由 request 层提示(文案可读) */
}
finally {
applying.value = false
}
}
/** 清除优惠(两空 = 清除)。用户手动清除后,本轮不再自动回填免单券(守卫 P0)。 */
async function onClearVoucher() {
autoSelectHandled = true
if (applying.value || !isPending.value)
return
applying.value = true
try {
const res = await apiMemberOrderApplyVoucher(outTradeNo.value, '', 0)
uni.showToast({ title: res.message || '已清除优惠', icon: 'none' })
await load()
}
catch {
/* 已由 request 层提示 */
}
finally {
applying.value = false
}
}
/** 选择免单券(与优惠码互斥:选中券会替换掉已应用的码)。抽屉内点选即生效并关闭。 */
async function onPickCoupon(id: number) {
if (applying.value || !isPending.value)
return
if (appliedCode.value) {
uni.showToast({ title: '已使用优惠码,无法再选优惠券', icon: 'none' })
return
}
if (id === appliedCouponId.value) {
// 已是当前选中项:视为「确认保持」,只关抽屉、不改状态(取消改由「不使用优惠券」承担)
couponDrawerVisible.value = false
return
}
applying.value = true
try {
const res = await apiMemberOrderApplyVoucher(outTradeNo.value, '', id)
uni.showToast({ title: res.message || '优惠券已应用', icon: 'none' })
couponNoneChosen.value = false
await load()
couponDrawerVisible.value = false
}
catch {
/* 4011 已由 request 层提示 */
}
finally {
applying.value = false
}
}
/**
* 抽屉内「不使用优惠券」:等价清空(两空 = 清除,复用既有 onClearVoucher)。
* 已是「无券」态时仅关抽屉(幂等,不打扰)。置 couponNoneChosen 让入口行显示「不使用优惠券」。
*/
async function onUseNoneCoupon() {
if (applying.value || !isPending.value)
return
couponNoneChosen.value = true
if (appliedCouponId.value <= 0) {
couponDrawerVisible.value = false
return
}
await onClearVoucher()
couponDrawerVisible.value = false
}
/**
* 订单默认自动选券(本页只做一次)。
*
* 条件:待支付(status=0)、订单当前**未使用任何凭证**(promo_kind 既非免单券也非优惠码)、
* 且存在可选免单券。
* 选择:URL 带来的 prefillCouponId 若在可选列表内则优先用它,否则取 coupons[0]。
* —— 为何 coupons[0] 即「最大优惠」:后端 /coupon/usable 已按 `expire_at ASC, id DESC` 排序,
* 且所有免单券实付价恒等于免单道具价(各券优惠额相同)→「最大优惠」是平局,
* 平局规则 = 到期最早 = 首元素。
* **绝不**自动应用优惠码(优惠码必须用户手动输入)。
* 守卫:只跑一次;标志在 await 之前置位(否则 onPickCoupon→load 会递归);applying 中跳过。
* 成功 / 失败都静默(不弹 toast;UI 已有「已选免单券」标签)。
*/
async function autoSelectCoupon() {
if (autoSelectHandled || applying.value)
return
if (!isPending.value || coupons.value.length === 0)
return
const kind = order.value?.promo_kind
if (kind === PROMO_KIND_COUPON || kind === PROMO_KIND_CODE)
return
const preferred = prefillCouponId.value > 0
? coupons.value.find(c => c.user_coupon_id === prefillCouponId.value)
: undefined
const pick = preferred || coupons.value[0]
if (!pick)
return
// 先置位、再 await:防止 pickCouponSilently 触发的 load() 再次进入本函数造成递归
autoSelectHandled = true
await pickCouponSilently(pick.user_coupon_id)
}
/** 静默应用免单券(自动选券专用:不弹 toast,失败也不打扰用户) */
async function pickCouponSilently(id: number) {
if (applying.value || !isPending.value)
return
if (id === appliedCouponId.value)
return
applying.value = true
try {
await apiMemberOrderApplyVoucher(outTradeNo.value, '', id, true)
await load()
}
catch {
/* 自动选券静默:失败不打扰(用户仍可手动选券) */
}
finally {
applying.value = false
}
}
async function pay() {
if (paying.value || !isPending.value)
return
paying.value = true
try {
// 会员套餐 → /member/order/pay;次数包 → /quota/order/pay(两个端点同构,发货链路共用)
const isQuota = orderType.value === 2
const result = isQuota
? await payQuotaOrder(outTradeNo.value)
: await payMemberOrder(outTradeNo.value)
if (result.delivered)
uni.showToast({ title: isQuota ? '支付成功,次数已到账' : '支付成功,已开通', icon: 'success' })
else
uni.showToast({ title: '支付已完成,正在开通', icon: 'none' })
// 等级 / 工具锁定态 / 额度立即刷新;刷新失败不把「已支付」误报为失败
await Promise.all([
userStore.refreshLevel().catch(() => {}),
toolsStore.fetchAll().catch(() => {}),
quotaStore.fetchMine().catch(() => {}),
load(),
])
}
catch (e) {
const err = e as { message?: string, cancelled?: boolean, detail?: string }
if (err?.cancelled)
return
const title = err?.message || '支付未完成'
if (err?.detail)
uni.showModal({ title, content: err.detail, showCancel: false })
else
uni.showToast({ title, icon: 'none' })
}
finally {
paying.value = false
}
}
function goBack() {
uni.navigateBack()
}
/** 已关闭 / 已退款:回到购买页重新下单 */
function goRebuy() {
if (orderType.value === 2 && order.value?.tool_key)
uni.navigateTo({ url: `/pkg-member/quota/index?tool_key=${order.value.tool_key}` })
else
uni.navigateTo({ url: '/pkg-member/member/index' })
}
onLoad((options) => {
outTradeNo.value = (options?.out_trade_no as string) || ''
orderType.value = Number(options?.order_type || 0)
targetKey.value = (options?.target_key as string) || ''
const cid = Number(options?.user_coupon_id || 0)
if (Number.isFinite(cid) && cid > 0)
prefillCouponId.value = Math.floor(cid)
})
onShow(() => {
if (outTradeNo.value)
load()
})
</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 v-if="loading && !order" class="hint">
正在加载订单…
</view>
<template v-else-if="order">
<!-- 状态条 -->
<view class="status" :class="`status--${status}`">
<text class="status__text">{{ statusText || '订单' }}</text>
<text v-if="isClosed" class="status__sub">已关闭,请重新下单</text>
<text v-else-if="isPending" class="status__sub">待支付,请在有效期内完成支付</text>
<text v-else-if="isDelivered" class="status__sub">权益已生效</text>
</view>
<!-- 商品 -->
<view class="card">
<text class="card__title">
商品
</text>
<view class="goods">
<text class="goods__name">{{ order.title || order.plan_name || '订单' }}</text>
<text v-if="order.order_type === 2" class="goods__meta">
{{ order.tool_name || order.tool_key || '—' }} · {{ order.times }} 次
</text>
<text class="goods__no">订单号:{{ order.out_trade_no }}</text>
</view>
</view>
<!-- 金额三行(未优惠时「已优惠」整行隐藏) -->
<view class="card">
<text class="card__title">
金额
</text>
<view class="amount">
<view class="amount__row">
<text class="amount__label">原价</text>
<text class="amount__value">¥{{ fenToYuan(originFen) }}</text>
</view>
<view v-if="showDiscount" class="amount__row">
<text class="amount__label">已优惠</text>
<text class="amount__value amount__value--cut">-¥{{ fenToYuan(discountFen) }}</text>
</view>
<view class="amount__row amount__row--pay">
<text class="amount__label amount__label--pay">应付</text>
<text class="amount__value amount__value--pay">¥{{ fenToYuan(paidFen) }}</text>
</view>
</view>
</view>
<!-- 优惠:仅待支付可编辑 -->
<view v-if="isPending" class="card">
<text class="card__title">
优惠
</text>
<PromoCodeInput
:applied="appliedCode"
:disabled="applying || !!appliedCouponId"
:loading="applying"
@apply="onApplyCode"
@clear="onClearVoucher"
/>
<!-- 优惠券入口:点击打开抽屉选择;券 / 码互斥(已用码则禁用选券,保持既有语义与文案) -->
<view
class="cp-entry press"
:class="{ 'cp-entry--off': !!appliedCode }"
hover-class="press-active"
@tap="openCouponDrawer"
>
<view class="cp-entry__left">
<text class="cp-entry__label">优惠券</text>
<text
class="cp-entry__value"
:class="{ 'cp-entry__value--on': !!appliedCouponId, 'cp-entry__value--none': !appliedCouponId }"
>
{{ couponEntryText }}
</text>
</view>
<view class="cp-entry__right">
<view v-if="appliedCouponId" class="cp-entry__check">
<wd-icon name="check" size="20rpx" color="#ffffff" />
</view>
<wd-icon name="arrow-right" size="30rpx" color="#b3907c" />
</view>
</view>
<text v-if="appliedCode" class="cp-entry__hint">
已使用优惠码,优惠券不可叠加
</text>
</view>
<view class="foot" />
<!-- 底部操作条 -->
<view class="bar" :style="{ zIndex: Z.stickyBar }">
<template v-if="isPending">
<view class="bar__price">
<text class="bar__symbol">¥</text>
<text class="bar__num">{{ fenToYuan(paidFen) }}</text>
</view>
<view
class="bar__btn press"
:class="{ 'bar__btn--off': paying }"
hover-class="press-active"
@tap="pay"
>
<text>{{ paying ? '处理中…' : '去支付' }}</text>
</view>
</template>
<template v-else-if="isClosed">
<view class="bar__btn bar__btn--primary press" hover-class="press-active" @tap="goRebuy">
<text>重新下单</text>
</view>
</template>
<template v-else>
<view class="bar__btn bar__btn--done press" hover-class="press-active" @tap="goBack">
<text>完成</text>
</view>
</template>
</view>
</template>
<view v-else class="hint">
订单不存在或不属于当前用户
</view>
<!--
优惠券选择抽屉:**页面根级**渲染(不传 root-portal,规避「scroll-view 内 root-portal 失效」旧坑)。
层级取 Z.sheet(2000) > 底部操作条 stickyBar(900),完整盖过操作条,二者不打架。
-->
<wd-popup
v-model="couponDrawerVisible"
position="bottom"
:z-index="Z.sheet"
custom-style="border-radius: 36rpx 36rpx 0 0;"
>
<view class="cd">
<view class="cd__grip" />
<text class="cd__title">
选择优惠券
</text>
<text class="cd__sub">
与优惠码互斥,选择后立即生效
</text>
<scroll-view scroll-y class="cd__list" :show-scrollbar="false">
<!-- 不使用优惠券(置顶;当前未选券时高亮打勾) -->
<view
class="cp cd__none press"
:class="{ 'cp--on': !appliedCouponId }"
hover-class="press-active"
@tap="onUseNoneCoupon"
>
<view class="cp__body">
<text class="cp__title">不使用优惠券</text>
<text class="cp__sub">以原价支付本单</text>
</view>
<view v-if="!appliedCouponId" class="cp__on">
<wd-icon 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 }"
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>
<view v-if="c.user_coupon_id === appliedCouponId" class="cp__on">
<wd-icon name="check" size="22rpx" color="#ffffff" />
</view>
</view>
<view v-if="!coupons.length" class="cd__empty">
暂无可用的免单券
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(200rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 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;
}
.hint {
text-align: center;
padding: 80rpx 0;
font-size: 26rpx;
color: #9a7b65;
}
.status {
border-radius: 28rpx;
padding: 26rpx 28rpx;
margin-bottom: 22rpx;
background: #fff4d6;
border: 2rpx solid #ffe3a8;
}
.status--1 {
background: #dff7f0;
border-color: #b8ecdd;
}
.status--2,
.status--3 {
background: #f2eee9;
border-color: #e2d8cf;
}
.status__text {
display: block;
font-size: 32rpx;
font-weight: 800;
color: #3d2a1e;
}
.status__sub {
display: block;
margin-top: 8rpx;
font-size: 23rpx;
color: #9a7b65;
}
.card {
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
padding: 26rpx 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.card__title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
margin-bottom: 20rpx;
}
.goods__name {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #3d2a1e;
}
.goods__meta {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #9a7b65;
}
.goods__no {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: #b3907c;
word-break: break-all;
}
.amount__row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10rpx 0;
}
.amount__label {
font-size: 26rpx;
color: #9a7b65;
text-align: right;
}
.amount__value {
font-size: 27rpx;
color: #3d2a1e;
text-align: right;
}
.amount__value--cut {
color: #0fa385;
}
.amount__row--pay {
border-top: 2rpx dashed #ffe3d3;
margin-top: 6rpx;
padding-top: 16rpx;
}
.amount__label--pay {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.amount__value--pay {
font-size: 40rpx;
font-weight: 800;
color: #fb4e14;
}
/* 优惠券入口行:点击打开选择抽屉(复用券卡视觉语言) */
.cp-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 18rpx;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
}
.cp-entry--off {
opacity: 0.5;
}
.cp-entry__left {
display: flex;
align-items: center;
gap: 16rpx;
min-width: 0;
}
.cp-entry__label {
flex-shrink: 0;
font-size: 26rpx;
color: #3d2a1e;
}
.cp-entry__value {
font-size: 26rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cp-entry__value--on {
color: #e8531a;
}
.cp-entry__value--none {
color: #9a7b65;
font-weight: 400;
}
.cp-entry__right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10rpx;
}
.cp-entry__check {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #0fa385;
display: flex;
align-items: center;
justify-content: center;
}
.cp-entry__hint {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: #b3907c;
}
/* 优惠券选择抽屉(页面根级 + sheet 层级) */
.cd {
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 20rpx 32rpx calc(36rpx + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
}
.cd__grip {
align-self: center;
width: 72rpx;
height: 8rpx;
border-radius: 999rpx;
background: #ffe3d3;
margin-bottom: 16rpx;
}
.cd__title {
font-size: 34rpx;
font-weight: 800;
color: #3d2a1e;
}
.cd__sub {
margin-top: 6rpx;
font-size: 24rpx;
color: #9a7b65;
}
.cd__list {
max-height: 52vh;
margin-top: 20rpx;
}
.cd__empty {
text-align: center;
padding: 40rpx 0;
font-size: 24rpx;
color: #b3907c;
}
.cp {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 24rpx;
border-radius: 22rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
/* 抽屉内逐条堆叠的间距(原内联列表靠 .coupons 的 gap,已移除) */
margin-bottom: 14rpx;
}
.cp--on {
background: #fff4d6;
border-color: #f5c56b;
box-shadow: 0 8rpx 18rpx rgba(224, 166, 63, 0.2);
}
.cp__body {
min-width: 0;
}
.cp__title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.cp__sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #9a7b65;
}
.cp__on {
flex-shrink: 0;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #0fa385;
display: flex;
align-items: center;
justify-content: center;
}
.foot {
height: 20rpx;
}
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.97);
border-top: 2rpx solid #ffe9dd;
box-shadow: 0 -10rpx 30rpx rgba(107, 47, 15, 0.08);
}
.bar__price {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
color: #fb4e14;
}
.bar__symbol {
font-size: 24rpx;
font-weight: 700;
}
.bar__num {
font-size: 44rpx;
font-weight: 800;
line-height: 1.1;
}
.bar__btn {
flex-shrink: 0;
padding: 24rpx 52rpx;
border-radius: 999rpx;
font-size: 29rpx;
font-weight: 800;
color: #3d2413;
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 52%, #d89a35 100%);
box-shadow: 0 12rpx 28rpx rgba(216, 154, 53, 0.36);
}
.bar__btn--primary {
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
flex: 1;
text-align: center;
}
.bar__btn--done {
flex: 1;
text-align: center;
color: #9a7b65;
background: #fff9f2;
box-shadow: none;
border: 2rpx solid #ffe3d3;
}
.bar__btn--off {
opacity: 0.5;
}
</style>

View File

@@ -9,21 +9,16 @@
* 会员状态卡(金)+ 额度概览(三池)→ 等级权益 → 套餐 → 须知 → 底部购买条
*/
import type { MemberCenter, MemberPlan } from '@/api/member'
import type { VirtualPayError } from '@/utils/vpay'
import { onShow } from '@dcloudio/uni-app'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { apiMemberCenter } from '@/api/member'
import Confetti from '@/components/Confetti.vue'
import Sticker from '@/components/Sticker.vue'
import { useQuotaStore } from '@/stores/quota'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { buyMembership, fenToYuan } from '@/utils/member-pay'
import { createMemberOrder, fenToYuan } from '@/utils/member-pay'
import { heroPadTop } from '@/utils/safearea'
import { Z } from '@/utils/z-index'
const userStore = useUserStore()
const toolsStore = useToolsStore()
const quotaStore = useQuotaStore()
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
@@ -31,7 +26,8 @@ const loading = ref(true)
const center = ref<MemberCenter | null>(null)
const selected = ref('')
const paying = ref(false)
const burst = ref(0)
/** 从「我的优惠券」带过来的免单券 id:建单后随跳转到详情页自动应用 */
const prefillCouponId = ref(0)
const plans = computed(() => center.value?.plans || [])
const levels = computed(() => center.value?.levels || [])
@@ -80,6 +76,12 @@ async function load() {
onShow(load)
onLoad((options) => {
const id = Number((options as Record<string, unknown> | undefined)?.user_coupon_id || 0)
if (Number.isFinite(id) && id > 0)
prefillCouponId.value = Math.floor(id)
})
function goBack() {
uni.navigateBack()
}
@@ -88,6 +90,11 @@ function goOrders() {
uni.navigateTo({ url: '/pkg-member/member/orders' })
}
/** 我的优惠券(双向可达:会员中心 ↔ 优惠券页) */
function goCoupons() {
uni.navigateTo({ url: '/pkg-member/coupon/index' })
}
function goMyQuota() {
uni.navigateTo({ url: '/pkg-member/quota/my' })
}
@@ -112,35 +119,17 @@ async function pay() {
}
paying.value = true
try {
const result = await buyMembership(plan.plan_key)
if (result.delivered) {
burst.value++
uni.showToast({ title: '开通成功', icon: 'success' })
// 等级、工具锁定态、额度都必须立即刷新,否则用户看到权益没生效。
// 刷新失败不应反过来把「已开通」误报成失败,故各自吞掉异常。
await Promise.all([
userStore.refreshLevel().catch(() => {}),
toolsStore.fetchAll().catch(() => {}),
quotaStore.fetchMine().catch(() => {}),
load(),
])
}
else {
uni.showToast({ title: '支付已完成,正在开通', icon: 'none' })
goOrders()
}
// 两步式第一步:只建单,**不弹收银台**;建单成功后进入订单详情,
// 由用户在详情页确认金额/可用优惠后再支付(N1)。
const created = await createMemberOrder(plan.plan_key)
// 从「我的优惠券」带过来的免单券随跳转落到详情页,自动预选(互斥由详情页保证)
const couponQuery = prefillCouponId.value > 0 ? `&user_coupon_id=${prefillCouponId.value}` : ''
uni.navigateTo({
url: `/pkg-member/member/detail?out_trade_no=${created.out_trade_no}&order_type=1&target_key=${plan.plan_key}${couponQuery}`,
})
}
catch (e) {
const err = e as VirtualPayError
// 用户主动关闭收银台不算失败,静默返回,避免弹窗打扰
if (err?.cancelled)
return
const title = err?.message || '支付未完成'
// 平台返回的排查说明较长,用模态才能完整展示
if (err?.detail)
uni.showModal({ title, content: err.detail, showCancel: false })
else
uni.showToast({ title, icon: 'none' })
catch {
/* 错误已由 request 层统一提示 */
}
finally {
paying.value = false
@@ -236,6 +225,25 @@ async function pay() {
<!-- 上浮内容 -->
<view class="body">
<!-- 我的优惠券入口(免单券可直接抵扣订单金额) -->
<view class="card sec coupon-entry press" hover-class="press-active" @tap="goCoupons">
<view class="coupon-entry__icon">
<wd-icon name="discount-filled" size="36rpx" color="#B57F00" />
</view>
<view class="coupon-entry__body">
<text class="coupon-entry__title">
我的优惠券
</text>
<text class="coupon-entry__sub">
免单券可直接抵扣订单金额
</text>
</view>
<view class="coupon-entry__go">
<text>查看</text>
<wd-icon name="arrow-right" size="24rpx" color="#B57F00" />
</view>
</view>
<!-- 等级权益 -->
<view class="card sec">
<view class="sec__head">
@@ -349,7 +357,7 @@ async function pay() {
</view>
<!-- 底部购买条 -->
<view class="buy">
<view class="buy" :style="{ zIndex: Z.stickyBar }">
<view class="buy__info">
<view class="buy__price">
<text class="buy__symbol">
@@ -372,8 +380,6 @@ async function pay() {
<text>{{ paying ? '处理中…' : '立即开通' }}</text>
</view>
</view>
<Confetti :burst="burst" />
</view>
</template>
@@ -630,6 +636,49 @@ async function pay() {
color: #9a7b65;
}
/* ---- 我的优惠券入口 ---- */
.coupon-entry {
display: flex;
align-items: center;
gap: 18rpx;
background: linear-gradient(140deg, #fff9ef, #fdf0d8);
border-color: rgba(224, 166, 63, 0.5);
}
.coupon-entry__icon {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
background: #fff4d6;
display: flex;
align-items: center;
justify-content: center;
}
.coupon-entry__body {
flex: 1;
min-width: 0;
}
.coupon-entry__title {
display: block;
font-size: 28rpx;
font-weight: 800;
color: #3d2a1e;
}
.coupon-entry__sub {
display: block;
margin-top: 6rpx;
font-size: 21rpx;
color: #9a7b65;
}
.coupon-entry__go {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2rpx;
font-size: 23rpx;
color: #b57f00;
}
/* ---- 等级权益 ---- */
.level {
display: flex;
@@ -823,7 +872,6 @@ async function pay() {
left: 0;
right: 0;
bottom: 0;
z-index: 8;
display: flex;
align-items: center;
gap: 20rpx;

View File

@@ -52,6 +52,17 @@ function goBuy(order?: MemberOrder) {
uni.navigateTo({ url: '/pkg-member/member/index' })
}
/** 「去支付」:进入该待支付订单详情,由用户在详情页确认金额/优惠后再拉起收银台 */
function goDetail(order: MemberOrder) {
// target_key 用于详情页查可选免单券:会员=plan_key、次数包=pack_key(**不可用 tool_key**)。
// 详情页也会从响应自身推导 target_key,此处为 query 兜底。
const target = order.order_type === 2 ? order.pack_key : order.plan_key
const key = target ? `&target_key=${target}` : ''
uni.navigateTo({
url: `/pkg-member/member/detail?out_trade_no=${order.out_trade_no}&order_type=${order.order_type}${key}`,
})
}
/** 手动触发一次服务端查单兜底:支付成功但推送未到达时用它确认发货 */
async function checkOrder(order: MemberOrder) {
if (checking.value)
@@ -93,7 +104,13 @@ async function checkOrder(order: MemberOrder) {
</view>
<view v-if="orders.length" class="list">
<view v-for="o in orders" :key="o.out_trade_no" class="tb-card item">
<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)"
>
<view class="item__top">
<text class="item__name">
{{ o.title || o.plan_name || o.plan_key || '订单' }}
@@ -147,14 +164,24 @@ async function checkOrder(order: MemberOrder) {
<view
class="item__btn press"
hover-class="press-active"
@tap="checkOrder(o)"
@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="goBuy(o)"
@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>
@@ -162,7 +189,7 @@ async function checkOrder(order: MemberOrder) {
</view>
<view v-if="hasPending" class="tip">
<text>待支付订单可点击「查询状态」确认是否已到账;订单号不可复用,未完成的订单请重新购买。</text>
<text>待支付订单点「支付」或点整张卡片都能进入订单详情,确认金额/优惠后再拉起收银台;已发货订单不可再次支付,需要时请联系客服。</text>
</view>
</view>
@@ -224,6 +251,13 @@ async function checkOrder(order: MemberOrder) {
padding: 28rpx;
margin-bottom: 24rpx;
}
/* 待支付订单:整张卡片可点进详情 */
.item--tappable {
transition: transform 0.2s ease;
}
.item--tappable:active {
transform: scale(0.99);
}
.item__top {
display: flex;
align-items: center;

View File

@@ -10,7 +10,6 @@
* 入口有三处:首页卡片、工具页额度胶囊、额度用尽弹层;都可以带上 ?tool_key= 直接定位工具。
*/
import type { QuotaPack, ToolQuota } from '@/api/quota'
import type { VirtualPayError } from '@/utils/vpay'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { apiMemberCenter } from '@/api/member'
@@ -18,8 +17,9 @@ import { apiQuotaPacks } from '@/api/quota'
import { useQuotaStore } from '@/stores/quota'
import { useUserStore } from '@/stores/user'
import { fenToYuan } from '@/utils/member-pay'
import { buyQuotaPack } from '@/utils/quota-pay'
import { createQuotaOrder } from '@/utils/quota-pay'
import { heroPadTop } from '@/utils/safearea'
import { Z } from '@/utils/z-index'
const userStore = useUserStore()
const quotaStore = useQuotaStore()
@@ -122,28 +122,16 @@ async function pay() {
}
paying.value = true
try {
const result = await buyQuotaPack(pack.pack_key, toolKey.value)
// 无论服务端是否已确认发货,都刷新一次额度;未发货时后续查单兜底会补上
await Promise.all([
quotaStore.fetchMine().catch(() => {}),
quotaStore.fetchTool(toolKey.value).catch(() => {}),
])
await loadPacks().catch(() => {})
if (result.delivered)
uni.showToast({ title: `已到账 ${result.times} 次`, icon: 'success' })
else
uni.showToast({ title: '支付已完成,正在到账', icon: 'none' })
// 两步式第一步:只建单,**不弹收银台**;建单成功后进入订单详情,
// 由用户在详情页确认金额/可用优惠后再支付(N1)。
const created = await createQuotaOrder(pack.pack_key, toolKey.value)
uni.navigateTo({
// target_key 必须是**次数包 key**(订单详情页据此查可选免单券),不可传 tool_key
url: `/pkg-member/member/detail?out_trade_no=${created.out_trade_no}&order_type=2&target_key=${pack.pack_key}`,
})
}
catch (e) {
const err = e as VirtualPayError
// 用户主动关闭收银台不算失败,静默返回,避免弹窗打扰
if (err?.cancelled)
return
const title = err?.message || '支付未完成'
if (err?.detail)
uni.showModal({ title, content: err.detail, showCancel: false })
else
uni.showToast({ title, icon: 'none' })
catch {
/* 错误已由 request 层统一提示 */
}
finally {
paying.value = false
@@ -308,7 +296,7 @@ async function pay() {
</view>
<!-- 底部购买条 -->
<view class="buy">
<view class="buy" :style="{ zIndex: Z.stickyBar }">
<view class="buy__info">
<view class="buy__price">
<text class="buy__symbol">
@@ -620,7 +608,6 @@ async function pay() {
left: 0;
right: 0;
bottom: 0;
z-index: 8;
display: flex;
align-items: center;
gap: 20rpx;

580
src/pkg-note/edit.vue Normal file
View File

@@ -0,0 +1,580 @@
<script setup lang="ts">
/**
* 笔记编辑页(分包 pkg-note)。
*
* 职责:标题 / 正文 / 标签 / 置顶 / 定时提醒 的新建与编辑,以及删除。
*
* 设计要点:
* - 新建(无 id)与编辑(id>0)共用一页;`id=0` 后端按「新建计额度、编辑不计」处理(R6-5);
* - 定提醒时按微信「订阅消息」语义申请**一次授权 = 一次可发送**(R6-10):在「保存」点按
* 的手势内先 `uni.requestSubscribeMessage`(必须处在用户点按上下文,否则微信拒绝),
* 授权成功后带**真实 note_id** 回传 `/note/subscribe-report` 累加可发送次数;
* - **拒绝授权不阻断保存**:提醒照存,只是「仅在应用内可见」——不做失败弹窗卡死(降级,R6-12)。
*/
import { apiNoteDelete, apiNoteDetail, apiNoteSave, apiNoteSubscribeReport, CODE_NOTE_LIMIT, CODE_NOTE_NOT_FOUND } from '@/api/note'
import { NOTE_REMIND_TMPL_ID } from '@/config/features'
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { heroPadTop } from '@/utils/safearea'
const heroPad = heroPadTop()
const id = ref(0)
const title = ref('')
const content = ref('')
const tags = ref<string[]>([])
const tagInput = ref('')
const pinned = ref(false)
/** 提醒时间:datetime 类型 = 毫秒时间戳;'' = 未设置 */
const remindValue = ref<number | string>('')
/** 加载时的原始提醒时间串,用于判断「本次是否重设了提醒」(决定是否申请授权) */
const originalRemindAt = ref('')
/** 允许选择的最小提醒时间(过去时间不可选,R6-9) */
const minRemindDate = Date.now()
const loading = ref(false)
const submitting = ref(false)
const deleting = ref(false)
const notFound = ref(false)
const pageTitle = computed(() => (id.value > 0 ? '编辑笔记' : '新建笔记'))
// ===== 日期时间工具(跨端安全:iOS/微信需把 '-' 转 '/')=====
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
function formatDateTime(ms: number): string {
const d = new Date(ms)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function parseDateTime(s: string): number {
if (!s)
return 0
const ms = new Date(s.replace(/-/g, '/')).getTime()
return Number.isFinite(ms) ? ms : 0
}
/** 当前提醒值 → 后端需要的字符串(空串=取消提醒) */
function currentRemindPayload(): string {
const v = remindValue.value
if (v === '' || v === null || v === undefined)
return ''
const ms = typeof v === 'number' ? v : parseDateTime(String(v))
return ms > 0 ? formatDateTime(ms) : ''
}
// ===== 加载 =====
onLoad((options) => {
const raw = options && options.id ? Number(options.id) : 0
id.value = Number.isFinite(raw) && raw > 0 ? raw : 0
uni.setNavigationBarTitle({ title: pageTitle.value })
if (id.value > 0)
loadDetail()
})
async function loadDetail() {
loading.value = true
try {
const d = await apiNoteDetail(id.value)
title.value = d.title
content.value = d.content
tags.value = [...d.tags]
pinned.value = d.is_pinned
originalRemindAt.value = d.remind_at || ''
remindValue.value = d.remind_at ? parseDateTime(d.remind_at) : ''
}
catch (e) {
if ((e as { code?: number }).code === CODE_NOTE_NOT_FOUND) {
notFound.value = true
uni.showToast({ title: '笔记不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
}
}
finally {
loading.value = false
}
}
// ===== 标签编辑 =====
function addTag() {
const t = tagInput.value.trim().replace(/^#/, '')
if (!t)
return
if (t.length > 12) {
uni.showToast({ title: '单个标签最多 12 字', icon: 'none' })
return
}
if (tags.value.includes(t)) {
tagInput.value = ''
return
}
if (tags.value.length >= 8) {
uni.showToast({ title: '标签最多 8 个', icon: 'none' })
return
}
tags.value.push(t)
tagInput.value = ''
}
function removeTag(t: string) {
tags.value = tags.value.filter(x => x !== t)
}
// ===== 订阅消息授权(一次授权 = 一次可发送)=====
/**
* 申请一次订阅授权。必须在「用户点按」手势内同步发起(微信限制),
* 故在 onSave 中作为**首个 await 之前**的调用。
* 未配置模板 ID(NOTE_REMIND_TMPL_ID 为空)时直接返回 false → 走降级。
*/
function requestSubscribe(): Promise<boolean> {
let tmplId = ''
// #ifdef MP-WEIXIN
tmplId = NOTE_REMIND_TMPL_ID
// #endif
if (!tmplId)
return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
uni.requestSubscribeMessage({
tmplIds: [tmplId],
success: (res) => {
const status = (res as unknown as Record<string, unknown>)[tmplId]
resolve(status === 'accept')
},
fail: () => resolve(false),
})
})
}
// ===== 保存 =====
async function onSave() {
if (submitting.value || notFound.value)
return
const t = title.value.trim()
if (!t) {
uni.showToast({ title: '请填写标题', icon: 'none' })
return
}
const payloadRemind = currentRemindPayload()
// 「重设了提醒」= 本次提醒值非空且与加载时不同 → 需要重新授权
const remindChanged = !!payloadRemind && payloadRemind !== originalRemindAt.value
let authed = false
if (remindChanged) {
// 手势内同步发起授权;授权结果仅决定是否上报 + 提示,不阻断保存
authed = await requestSubscribe()
}
submitting.value = true
try {
const res = await apiNoteSave({
id: id.value,
title: t,
content: content.value,
tags: [...tags.value],
is_pinned: pinned.value,
remind_at: payloadRemind,
})
id.value = res.id
originalRemindAt.value = payloadRemind
if (remindChanged) {
if (authed) {
// 授权成功:回传可发送次数(失败不影响保存结果)
await apiNoteSubscribeReport(res.id, 1).catch(() => {})
uni.showToast({ title: '已保存并开启提醒', icon: 'success' })
backSoon()
}
else {
// 降级:提醒照存,仅应用内可见
uni.showModal({
title: '已保存',
content: '未开启微信提醒,该提醒仅在应用内可见;可在「我的笔记」重新设置以开启微信通知。',
showCancel: false,
confirmText: '知道了',
success: () => uni.navigateBack(),
})
}
}
else {
uni.showToast({ title: '已保存', icon: 'success' })
backSoon()
}
}
catch (e) {
const code = (e as { code?: number }).code
if (code === CODE_NOTE_LIMIT) {
uni.showModal({
title: '笔记已达上限',
content: '免费版最多 50 条,开通会员可不限量',
confirmText: '开通会员',
cancelText: '稍后再说',
success: (r) => {
if (r.confirm)
uni.navigateTo({ url: '/pkg-member/member/index' })
},
})
}
// 其它错误已由 request 层统一 toast
}
finally {
submitting.value = false
}
}
function backSoon() {
setTimeout(() => uni.navigateBack(), 500)
}
// ===== 删除 =====
function onDelete() {
if (id.value <= 0 || deleting.value)
return
uni.showModal({
title: '删除笔记',
content: '删除后进入回收站(7 天内可恢复),额度立即释放。',
confirmText: '删除',
confirmColor: '#E5484D',
success: async (r) => {
if (!r.confirm)
return
deleting.value = true
try {
await apiNoteDelete(id.value)
uni.showToast({ title: '已删除', icon: 'success' })
backSoon()
}
finally {
deleting.value = false
}
},
})
}
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">
{{ pageTitle }}
</text>
<view class="nav__save press" hover-class="press-active" @tap="onSave">
{{ submitting ? '保存中…' : '保存' }}
</view>
</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>
<template v-else>
<!-- 标题 -->
<view class="card tb-card tb-elev-1">
<text class="label">
标题
</text>
<input
v-model="title"
class="input"
placeholder="给笔记起个名字"
placeholder-class="ph"
:maxlength="128"
>
</view>
<!-- 正文 -->
<view class="card tb-card tb-elev-1">
<text class="label">
正文
</text>
<textarea
v-model="content"
class="textarea"
placeholder="随手记点什么…"
placeholder-class="ph"
:maxlength="20000"
auto-height
/>
</view>
<!-- 标签 -->
<view class="card tb-card tb-elev-1">
<text class="label">
标签
</text>
<view class="tags">
<view v-for="t in tags" :key="t" class="tag" @tap="removeTag(t)">
<text>#{{ t }}</text>
<wd-icon name="close-circle-filled" size="26rpx" color="#E8531A" />
</view>
</view>
<view class="tag-add">
<input
v-model="tagInput"
class="input input--inline"
placeholder="添加标签(回车确认)"
placeholder-class="ph"
:maxlength="12"
confirm-type="done"
@confirm="addTag"
>
<view class="tag-add__btn press" hover-class="press-active" @tap="addTag">
添加
</view>
</view>
</view>
<!-- 置顶 / 提醒 -->
<view class="card tb-card tb-elev-1">
<view class="row">
<view class="row__left">
<text class="row__title">
置顶
</text>
<text class="row__desc">
置顶的笔记会在列表最上方
</text>
</view>
<wd-switch v-model="pinned" />
</view>
<view class="divider" />
<wd-datetime-picker
v-model="remindValue"
type="datetime"
label="定时提醒"
placeholder="未设置"
:clearable="!!remindValue"
:min-date="minRemindDate"
/>
<text class="row__hint">
设置后将申请一次微信订阅消息授权(一次授权对应一次通知);未授权时提醒仅应用内可见。
</text>
</view>
<!-- 操作 -->
<view class="acts">
<view class="btn btn--primary press" hover-class="press-active" @tap="onSave">
{{ submitting ? '保存中…' : '保存' }}
</view>
<view
v-if="id > 0"
class="btn btn--danger press"
hover-class="press-active"
@tap="onDelete"
>
{{ deleting ? '删除中…' : '删除' }}
</view>
</view>
</template>
<view class="foot" />
</view>
</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__save {
flex-shrink: 0;
height: 58rpx;
padding: 0 30rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.96);
color: #ff6b35;
font-size: 27rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.22);
}
/* 卡片与表单 */
.card {
padding: 26rpx;
margin-bottom: 24rpx;
}
.label {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
}
.input {
width: 100%;
height: 72rpx;
font-size: 30rpx;
color: #3d2a1e;
}
.input--inline {
flex: 1;
height: 64rpx;
font-size: 27rpx;
}
.textarea {
width: 100%;
min-height: 260rpx;
font-size: 28rpx;
line-height: 1.6;
color: #3d2a1e;
}
.ph {
color: #c39a80;
}
/* 标签 */
.tags {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-bottom: 16rpx;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: #fff1e8;
border: 2rpx solid #ffd4b8;
font-size: 24rpx;
color: #e8531a;
}
.tag-add {
display: flex;
align-items: center;
gap: 16rpx;
}
.tag-add__btn {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 25rpx;
font-weight: 700;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.26);
}
/* 设置行 */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 6rpx 0;
}
.row__left {
flex: 1;
min-width: 0;
}
.row__title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.row__desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #9a7b65;
}
.row__hint {
display: block;
margin-top: 12rpx;
font-size: 21rpx;
line-height: 1.5;
color: #9a7b65;
}
.divider {
height: 1rpx;
background: #ffe9dd;
margin: 20rpx 0;
}
/* 操作按钮 */
.acts {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 8rpx;
}
.btn {
text-align: center;
border-radius: 999rpx;
padding: 26rpx 0;
font-size: 30rpx;
font-weight: 800;
}
.btn--primary {
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
box-shadow: 0 14rpx 32rpx rgba(251, 78, 20, 0.32);
}
.btn--danger {
color: #e5484d;
background: #ffffff;
border: 2rpx solid #ffd6d6;
}
.foot {
height: 20rpx;
}
</style>

631
src/pkg-note/list.vue Normal file
View File

@@ -0,0 +1,631 @@
<script setup lang="ts">
/**
* 笔记列表页(分包 pkg-note)。
*
* 职责:搜索(标题/正文)+ 标签筛选 + 「置顶优先、更新时间倒序」展示 + 额度计数(`已用/上限`)
* + 提醒标记 + 分页加载 + 空态;数据以后端为准,仅做「离线只读」缓存兜底。
*
* 设计要点(对齐 PRD-02 §5.1 / R6-1~R6-9):
* - 额度语义是**活跃条数上限**(非每月重置、不复用 quota.ts 三池);会员不限量;
* - 到达上限时「新建」置灰并弹会员引导(8003 由 request 层交由此页处理,避免双 toast);
* - 搜索/标签筛选均由后端 query 参数完成,前端不本地过滤,保证跨页一致。
*/
import type { NoteItem, NoteListData } from '@/api/note'
import {
apiNoteList,
CODE_NOTE_LIMIT,
NOTE_FREE_LIMIT,
NOTE_REMIND_EXPIRED,
NOTE_REMIND_FAILED,
NOTE_REMIND_PENDING,
NOTE_REMIND_SENT,
} from '@/api/note'
import { onReachBottom, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { heroPadTop } from '@/utils/safearea'
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
/** 列表本地缓存键(离线只读兜底;后端为权威源,清缓存后重新拉取即可) */
const CACHE_KEY = 'note_list_cache'
const PAGE_SIZE = 20
const loading = ref(false)
const notes = ref<NoteItem[]>([])
const total = ref(0)
const activeCount = ref(0)
const limit = ref(NOTE_FREE_LIMIT)
const isMember = ref(false)
const page = ref(1)
const keyword = ref('')
const activeTag = ref('')
const tagPool = ref<string[]>([])
/** 是否为「离线只读」(后端不可用、展示本地缓存) */
const offline = ref(false)
const hasMore = computed(() => notes.value.length < total.value)
const atLimit = computed(() => !isMember.value && activeCount.value >= limit.value)
/** 头区额度计数:会员显示不限量,免费显示 `已用 / 上限` */
const quotaText = computed(() => (isMember.value ? '不限量' : `${activeCount.value} / ${limit.value}`))
// ===== 数据加载 =====
function cacheWrite(data: NoteListData) {
try {
uni.setStorageSync(CACHE_KEY, JSON.stringify(data))
}
catch {
/* 缓存写失败不影响主流程 */
}
}
function cacheRead(): NoteListData | null {
try {
const raw = uni.getStorageSync(CACHE_KEY) as string
return raw ? (JSON.parse(raw) as NoteListData) : null
}
catch {
return null
}
}
function applyData(data: NoteListData, reset: boolean) {
total.value = data.total
activeCount.value = data.active_count
// 会员 limit=0(不限量):仅用于展示判断,这里回落到 50 以免出现「0 / 0」
limit.value = data.limit > 0 ? data.limit : NOTE_FREE_LIMIT
isMember.value = !!data.is_member
notes.value = reset ? data.list : notes.value.concat(data.list)
// 标签池:累积已加载笔记出现过的标签(用于筛选 chip)
const set = new Set(tagPool.value)
data.list.forEach(n => n.tags.forEach(t => set.add(t)))
tagPool.value = Array.from(set)
}
async function load(reset = true) {
if (loading.value)
return
if (reset)
page.value = 1
loading.value = true
try {
const data = await apiNoteList({
keyword: keyword.value.trim() || undefined,
tag: activeTag.value || undefined,
page: page.value,
pageSize: PAGE_SIZE,
})
applyData(data, reset)
offline.value = false
if (reset)
cacheWrite(data)
}
catch (e) {
const code = (e as { code?: number }).code
if (code === CODE_NOTE_LIMIT) {
showLimitGuide()
return
}
// 首次加载失败且当前无数据 → 回退本地缓存(离线只读,PRD Q16)
if (reset && notes.value.length === 0) {
const cached = cacheRead()
if (cached) {
applyData(cached, true)
offline.value = true
}
}
}
finally {
loading.value = false
}
}
onShow(() => {
load(true)
})
onReachBottom(() => {
if (loading.value || !hasMore.value)
return
page.value += 1
load(false)
})
// ===== 交互 =====
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput() {
if (searchTimer)
clearTimeout(searchTimer)
searchTimer = setTimeout(() => load(true), 350)
}
function clearKeyword() {
keyword.value = ''
load(true)
}
function pickTag(tag: string) {
if (activeTag.value === tag)
return
activeTag.value = tag
load(true)
}
function goBack() {
uni.navigateBack()
}
function goNew() {
if (atLimit.value) {
showLimitGuide()
return
}
uni.navigateTo({ url: '/pkg-note/edit' })
}
function goEdit(id: number) {
uni.navigateTo({ url: `/pkg-note/edit?id=${id}` })
}
function goMember() {
uni.navigateTo({ url: '/pkg-member/member/index' })
}
/** 到达上限的会员引导(4003 也走这里,避免两层提示) */
function showLimitGuide() {
uni.showModal({
title: '笔记已达上限',
content: `免费版最多 ${limit.value} 条,开通会员可不限量`,
confirmText: '开通会员',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm)
goMember()
},
})
}
// ===== 提醒状态展示(remind_status 0..4)=====
function remindLabel(status: number): string {
switch (status) {
case NOTE_REMIND_PENDING:
return '待提醒'
case NOTE_REMIND_SENT:
return '已提醒'
case NOTE_REMIND_FAILED:
return '提醒失败'
case NOTE_REMIND_EXPIRED:
return '已过期'
default:
return ''
}
}
function remindColor(status: number): string {
switch (status) {
case NOTE_REMIND_PENDING:
return '#FF6B35'
case NOTE_REMIND_SENT:
return '#0FA385'
case NOTE_REMIND_FAILED:
return '#E5484D'
case NOTE_REMIND_EXPIRED:
return '#B3907C'
default:
return '#B3907C'
}
}
function remindClass(status: number): string {
switch (status) {
case NOTE_REMIND_PENDING:
return 'remind--pending'
case NOTE_REMIND_SENT:
return 'remind--sent'
case NOTE_REMIND_FAILED:
return 'remind--failed'
default:
return 'remind--expired'
}
}
</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
class="nav__new press"
:class="{ 'nav__new--lock': atLimit }"
hover-class="press-active"
@tap="goNew"
>
<wd-icon name="add" size="30rpx" color="#ffffff" />
<text>新建</text>
</view>
</view>
<view class="quota">
<text class="quota__tip">
{{ isMember ? '会员 · 笔记不限量' : `免费版 ${limit} 条上限(会员不限量)` }}
</text>
<view class="quota__count" :class="{ 'quota__count--lock': atLimit }">
{{ quotaText }}
</view>
</view>
</view>
<!-- 上浮内容区 -->
<view class="tb-body">
<!-- 搜索 -->
<view class="tb-search tb-search--flat">
<wd-icon name="search" size="32rpx" color="#B3907C" />
<input
v-model="keyword"
class="tb-search__input"
placeholder="搜索标题 / 正文…"
placeholder-class="tb-search__ph"
confirm-type="search"
@input="onSearchInput"
@confirm="load(true)"
>
<view v-if="keyword" class="search__clear press" hover-class="press-active" @tap="clearKeyword">
<wd-icon name="close" size="26rpx" color="#B3907C" />
</view>
</view>
<!-- 标签筛选 -->
<scroll-view v-if="tagPool.length" scroll-x class="tagbar" :show-scrollbar="false">
<view class="tagbar__inner">
<view class="chip" :class="{ 'chip--on': !activeTag }" @tap="pickTag('')">
全部
</view>
<view
v-for="t in tagPool"
:key="t"
class="chip"
:class="{ 'chip--on': activeTag === t }"
@tap="pickTag(t)"
>
{{ t }}
</view>
</view>
</scroll-view>
<!-- 离线只读提示 -->
<view v-if="offline" class="offline">
<wd-icon name="help-circle" size="26rpx" color="#B57F00" />
<text>离线只读:网络恢复后自动同步</text>
</view>
<!-- 加载中(首次) -->
<view v-if="loading && !notes.length" class="tb-empty">
<wd-icon name="refresh" size="52rpx" color="#E8A87C" />
<text class="tb-empty__title">
正在加载…
</text>
</view>
<!-- 空态 -->
<view v-else-if="!notes.length" class="tb-empty">
<wd-icon name="edit-outline" size="56rpx" color="#E8A87C" />
<text class="tb-empty__title">
{{ keyword || activeTag ? '没有匹配的笔记' : '还没有笔记' }}
</text>
<text class="tb-empty__sub">
{{ keyword || activeTag ? '换个关键词或标签试试' : '点右上角「新建」随手记一条' }}
</text>
</view>
<!-- 列表 -->
<view v-else class="list">
<view
v-for="n in notes"
:key="n.id"
class="note tb-card tb-elev-1 press"
hover-class="press-active"
@tap="goEdit(n.id)"
>
<view class="note__head">
<view class="note__title-wrap">
<view v-if="n.is_pinned" class="note__pin">
<wd-icon name="pin" size="26rpx" color="#FF6B35" />
</view>
<text class="note__title">
{{ n.title }}
</text>
</view>
<view v-if="n.remind_status > 0" class="remind" :class="remindClass(n.remind_status)">
<wd-icon name="clock" size="22rpx" :color="remindColor(n.remind_status)" />
<text>{{ remindLabel(n.remind_status) }}</text>
</view>
</view>
<text v-if="n.summary" class="note__summary">
{{ n.summary }}
</text>
<view class="note__foot">
<view class="note__tags">
<text v-for="t in n.tags" :key="t" class="note__tag">#{{ t }}</text>
</view>
<text class="note__time">
{{ (n.updated_at || '').slice(0, 16) }}
</text>
</view>
<text
v-if="n.remind_status === NOTE_REMIND_PENDING && n.remind_at"
class="note__remind-at"
>
提醒时间:{{ n.remind_at.slice(0, 16) }}
</text>
</view>
<!-- 分页 / 到底 -->
<view class="more">
<text v-if="loading">加载中…</text>
<text v-else-if="hasMore" @tap="load(false)">加载更多</text>
<text v-else>没有更多了</text>
</view>
</view>
<view class="foot" />
</view>
</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__new {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
height: 58rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.24);
border: 2rpx solid rgba(255, 255, 255, 0.55);
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
}
.nav__new--lock {
opacity: 0.6;
}
/* 额度条 */
.quota {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 26rpx;
}
.quota__tip {
flex: 1;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
}
.quota__count {
flex-shrink: 0;
padding: 8rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.96);
font-size: 26rpx;
font-weight: 800;
color: #ff6b35;
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.2);
}
.quota__count--lock {
color: #e5484d;
}
/* 搜索 */
.search__clear {
flex-shrink: 0;
padding: 4rpx;
}
.tb-search__ph {
color: #b3907c;
}
/* 标签栏 */
.tagbar {
margin-top: 22rpx;
white-space: nowrap;
}
.tagbar__inner {
display: inline-flex;
gap: 14rpx;
padding: 2rpx 2rpx;
}
.chip {
display: inline-flex;
align-items: center;
padding: 12rpx 26rpx;
border-radius: 999rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
font-size: 24rpx;
color: #9a7b65;
}
.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.28);
}
/* 离线提示 */
.offline {
display: flex;
align-items: center;
gap: 10rpx;
margin-top: 22rpx;
padding: 16rpx 22rpx;
border-radius: 20rpx;
background: #fff4d6;
border: 2rpx solid #ffe2a8;
font-size: 22rpx;
color: #b57f00;
}
/* 列表 */
.list {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 24rpx;
}
.note {
padding: 26rpx 26rpx 22rpx;
}
.note__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.note__title-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8rpx;
}
.note__pin {
flex-shrink: 0;
display: flex;
align-items: center;
}
.note__title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 800;
color: #3d2a1e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remind {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
}
.remind--pending {
background: #fff1e8;
color: #ff6b35;
}
.remind--sent {
background: #e3f7f2;
color: #0fa385;
}
.remind--failed {
background: #ffe9ea;
color: #e5484d;
}
.remind--expired {
background: #f3ece6;
color: #9a7b65;
}
.note__summary {
display: block;
margin-top: 12rpx;
font-size: 25rpx;
line-height: 1.5;
color: #9a7b65;
}
.note__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
margin-top: 16rpx;
}
.note__tags {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.note__tag {
font-size: 21rpx;
color: #e8531a;
}
.note__time {
flex-shrink: 0;
font-size: 21rpx;
color: #c39a80;
}
.note__remind-at {
display: block;
margin-top: 10rpx;
font-size: 21rpx;
color: #ff6b35;
}
/* 分页 */
.more {
text-align: center;
padding: 18rpx 0;
font-size: 24rpx;
color: #b3907c;
}
.foot {
height: 20rpx;
}
</style>

152
src/pkg-style/icons.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* 卡片样式库图标注册表(分包 pkg-style 专用)。
*
* 全部**静态 import**:走 Vite 资产管线——H5 端 URL 正确、文件缺失直接编译报错;
* MP 端依赖 `manifest.json → mp-weixin.optimization.subPackages = true`
* 把「仅被本分包引用」的资产归入 dist 的 `pkg-style/` 目录(Step 1 已构建断言落点)。
*
* 生成管线:ImageGen 出 3×3 白底 sheet → Pillow 泛洪去底 + 切格(probes/stylekit)。
* 命名 = 语义 kebab-case;`hero-` 前缀为 288px 大图。
* ⚠️ ImageGen 不保证按 prompt 顺序出图——每张 sheet 切完必须目验蒙太奇核对名实。
*/
import avatarWoman from './static/icons/avatar-woman.png'
import anchor from './static/icons/anchor.png'
import atom from './static/icons/atom.png'
import badgeBolt from './static/icons/badge-bolt.png'
import badgeCrown from './static/icons/badge-crown.png'
import badgeHeart from './static/icons/badge-heart.png'
import badgeStar from './static/icons/badge-star.png'
import balloon from './static/icons/balloon.png'
import bell from './static/icons/bell.png'
import bookA from './static/icons/bookA.png'
import brain from './static/icons/brain.png'
import broballoon from './static/icons/broballoon.png'
import briefcase from './static/icons/briefcase.png'
import burger from './static/icons/burger.png'
import cake from './static/icons/cake.png'
import camera from './static/icons/camera.png'
import cameraretro from './static/icons/cameraretro.png'
import cart from './static/icons/cart.png'
import chart from './static/icons/chart.png'
import chat from './static/icons/chat.png'
import chatdots from './static/icons/chatdots.png'
import clipboard from './static/icons/clipboard.png'
import clock from './static/icons/clock.png'
import cloudDown from './static/icons/cloud-down.png'
import cloudVideo from './static/icons/cloud-video.png'
import coffee from './static/icons/coffee.png'
import college from './static/icons/college.png'
import community from './static/icons/community.png'
import compass from './static/icons/compass.png'
import dinner from './static/icons/dinner.png'
import docscheck from './static/icons/docscheck.png'
import donut from './static/icons/donut.png'
import exampaper from './static/icons/exampaper.png'
import film from './static/icons/film.png'
import folder from './static/icons/folder.png'
import gamepad from './static/icons/gamepad.png'
import gift from './static/icons/gift.png'
import gradcap from './static/icons/gradcap.png'
import hearts from './static/icons/hearts.png'
import heroBot from './static/icons/hero-bot.png'
import icepop from './static/icons/icepop.png'
import live from './static/icons/live.png'
import lovepop from './static/icons/lovepop.png'
import majorbag from './static/icons/majorbag.png'
import mask from './static/icons/mask.png'
import message from './static/icons/message.png'
import mic from './static/icons/mic.png'
import miniapp from './static/icons/miniapp.png'
import notes from './static/icons/notes.png'
import notebook from './static/icons/notebook.png'
import orangeFruit from './static/icons/orange-fruit.png'
import papers from './static/icons/papers.png'
import pear from './static/icons/pear.png'
import pencil from './static/icons/pencil.png'
import persona from './static/icons/persona.png'
import plane from './static/icons/plane.png'
import planet from './static/icons/planet.png'
import resume from './static/icons/resume.png'
import scan from './static/icons/scan.png'
import script from './static/icons/script.png'
import selfbook from './static/icons/selfbook.png'
import shield from './static/icons/shield.png'
import shrimp from './static/icons/shrimp.png'
import store from './static/icons/store.png'
import suitcase from './static/icons/suitcase.png'
import vinyl from './static/icons/vinyl.png'
import wework from './static/icons/wework.png'
import wordbook from './static/icons/wordbook.png'
export const ICONS = {
'hero-bot': heroBot,
planet,
camera,
plane,
suitcase,
clipboard,
live,
script,
chart,
balloon,
'orange-fruit': orangeFruit,
pear,
shrimp,
clock,
gamepad,
gift,
community,
'avatar-woman': avatarWoman,
folder,
'cloud-down': cloudDown,
notebook,
shield,
message,
'cloud-video': cloudVideo,
resume,
gradcap,
mask,
papers,
selfbook,
exampaper,
mic,
lovepop,
icepop,
broballoon,
bell,
chat,
bookA,
notes,
pencil,
miniapp,
film,
compass,
persona,
brain,
donut,
'badge-star': badgeStar,
'badge-bolt': badgeBolt,
'badge-heart': badgeHeart,
'badge-crown': badgeCrown,
vinyl,
chatdots,
cameraretro,
hearts,
scan,
burger,
cake,
coffee,
wordbook,
college,
majorbag,
wework,
store,
cart,
docscheck,
briefcase,
atom,
anchor,
dinner,
} as const
export type IconKey = keyof typeof ICONS

View File

@@ -0,0 +1,825 @@
<script setup lang="ts">
/**
* 图一板块:复刻参考图一的 7 组瓷片区卡片。
*
* 01 欢迎卡(AI 助手) 02 美食专区 03 航班卡 04 更多活动
* 05 文件夹九宫格 06 Hi~康笑 07 蒙面约会/找个对象
*
* 每组 = .sk-sec(白卡展示台)+ .sk-cap(编号组名)+ 忠实复刻的卡片本体。
* 共享原子类见 style/showcase.scss;本组件 scoped 只放组内独有布局。
*/
import { ICONS } from '../icons'
/** 04 更多活动 2×2 数据 */
const acts = [
{ key: 'clock', tag: '新客福利 8.5折', title: '每日签到', desc: '签到兑换奖品', cls: 'act--violet' },
{ key: 'gamepad', tag: '集合变强变大龙', title: '游戏必备', desc: '游戏达人的福音', cls: 'act--mint' },
{ key: 'gift', tag: '最新积分兑换', title: '奖品礼物', desc: '积分可兑换奖品', cls: 'act--peach' },
{ key: 'community', tag: '10 倍积分返还', title: '交友社区', desc: '寻找志同道合的人', cls: 'act--rose' },
] as const
/** 05 文件夹九宫格 2×3 数据 */
const files = [
{ key: 'folder', title: '文件夹', desc: '文件夹描述', cls: 'file--blue' },
{ key: 'cloud-down', title: '云下载', desc: '云下载描述', cls: 'file--teal' },
{ key: 'notebook', title: '笔记本', desc: '笔记本描述', cls: 'file--orange' },
{ key: 'shield', title: '安全', desc: '安全描述', cls: 'file--indigo' },
{ key: 'message', title: '消息', desc: '消息描述', cls: 'file--pink' },
{ key: 'cloud-video', title: '云影像', desc: '云影像描述', cls: 'file--yellow' },
] as const
/** 01 欢迎卡三张竖版子卡 */
const subs = [
{ key: 'planet', title: '知识星球', desc: '知识应用由微软必应搜索全球专业解答', cls: 'sk-grad-violet' },
{ key: 'balloon', title: '避退之旅', desc: '将到成熟的旅游本地分享推荐', cls: 'sk-grad-sky' },
{ key: 'camera', title: '趣味摄咔', desc: '想重新更安排的搜索图索', cls: 'sk-grad-orange' },
] as const
</script>
<template>
<view class="wrap">
<!-- 01 欢迎卡 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">01</text>
<text class="sk-cap__name">欢迎卡 · AI 助手</text>
</view>
<view class="g1">
<view class="g1__top">
<image class="g1__bot" :src="ICONS['hero-bot']" mode="aspectFit" />
<view class="g1__hello">
<text class="g1__hi">嗨,欢迎使用小AI!</text>
<text class="g1__ask">有什么我可以帮助你解答答案的问题吗?</text>
</view>
</view>
<view class="g1__banner">
<text class="g1__bt">邀请朋友一起来体验,获得更多 AI 点赞~</text>
<view class="sk-pill sk-pill--white sk-pill--sm">
立即观看 ›
</view>
</view>
<view class="sk-row">
<view v-for="s in subs" :key="s.key" class="sk-card g1__sub" :class="s.cls">
<image class="g1__sico" :src="ICONS[s.key]" mode="aspectFit" />
<text class="g1__st">{{ s.title }}</text>
<text class="g1__sd">{{ s.desc }}</text>
<view class="g1__chip">
99+人添加
</view>
</view>
</view>
</view>
</view>
<!-- 02 美食专区 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">02</text>
<text class="sk-cap__name">美食专区 · 渐变大卡</text>
</view>
<view class="g2">
<view class="g2__head">
<text class="g2__ht">美食专区</text>
<view class="sk-pill sk-pill--white sk-pill--sm">
更多热门活动 ›
</view>
</view>
<view class="g2__body">
<view class="g2__left">
<text class="g2__lt">水果免单抽奖</text>
<text class="g2__ld">0元购,却能享受惊喜盛宴</text>
<view class="g2__price">
<text class="g2__pl">最高免单</text>
<text class="g2__pv">¥9999</text>
</view>
<view class="g2__btn">
立即抽奖
</view>
<image class="g2__fruit" :src="ICONS['orange-fruit']" mode="aspectFit" />
<text class="sk-ghost g2__ghost">FOOD</text>
</view>
<view class="g2__right">
<view class="g2__sm">
<view class="g2__smt">
进口水果 ›
</view>
<text class="g2__smd">享受舌尖上的美味时刻</text>
<image class="g2__smi" :src="ICONS.pear" mode="aspectFit" />
<text class="sk-ghost g2__ghost g2__ghost--sm">FRUIT</text>
</view>
<view class="g2__sm">
<view class="g2__smt">
海鲜狂欢 ›
</view>
<text class="g2__smd">海岸线上的美味调制</text>
<image class="g2__smi" :src="ICONS.shrimp" mode="aspectFit" />
<text class="sk-ghost g2__ghost g2__ghost--sm">SEAFO</text>
</view>
</view>
</view>
</view>
</view>
<!-- 03 航班卡 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">03</text>
<text class="sk-cap__name">航班卡 · 外卡内嵌</text>
</view>
<view class="g3">
<view class="g3__main">
<text class="g3__mt">今日航班</text>
<text class="g3__md">今日任务 412 班</text>
<view class="g3__set">
优先设置
</view>
<image class="g3__plane" :src="ICONS.plane" mode="aspectFit" />
</view>
<view class="g3__col">
<view class="g3__item">
<view class="g3__itt">
行李管理 ›
</view>
<text class="g3__itd">今日共送出 110 件</text>
<image class="g3__iti" :src="ICONS.suitcase" mode="aspectFit" />
</view>
<view class="g3__item">
<view class="g3__itt">
行李任务 ›
</view>
<text class="g3__itd">7 间任务待完成</text>
<image class="g3__iti" :src="ICONS.clipboard" mode="aspectFit" />
</view>
</view>
</view>
</view>
<!-- 04 更多活动 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">04</text>
<text class="sk-cap__name">更多活动 · 2×2 宫格</text>
</view>
<view class="g4">
<view v-for="a in acts" :key="a.key" class="g4__cell" :class="a.cls">
<view class="g4__tag">
{{ a.tag }}
</view>
<text class="g4__tt">{{ a.title }}</text>
<text class="g4__td">{{ a.desc }}</text>
<view class="sk-arr g4__arr">
›
</view>
<image class="g4__ico" :src="ICONS[a.key]" mode="aspectFit" />
</view>
</view>
</view>
<!-- 05 文件夹九宫格 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">05</text>
<text class="sk-cap__name">文件夹 · 2×3 彩底格</text>
</view>
<view class="g5">
<view v-for="f in files" :key="f.key" class="g5__cell" :class="f.cls">
<text class="g5__tt">{{ f.title }}</text>
<text class="g5__td">{{ f.desc }}</text>
<view class="sk-arr g5__arr">
›
</view>
<image class="g5__ico" :src="ICONS[f.key]" mode="aspectFit" />
</view>
</view>
</view>
<!-- 06 Hi~康笑 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">06</text>
<text class="sk-cap__name">导师卡 · 左人右列</text>
</view>
<view class="g6">
<view class="g6__left">
<text class="g6__hi">Hi~康笑</text>
<view class="g6__mid">
<image class="g6__ava" :src="ICONS['avatar-woman']" mode="aspectFit" />
<view class="g6__quote">
<text class="g6__qmark">“</text>
<text class="g6__qt">升级简历·面试机会提升 85%</text>
<text class="g6__qmark g6__qmark--end">”</text>
</view>
</view>
<view class="sk-pill g6__btn">
在线咨询 ›
</view>
</view>
<view class="g6__right">
<view class="g6__row">
<view class="g6__rt">
简历·讨论区
<text class="g6__hot">HOT</text>
</view>
<text class="g6__rd">1对1教你写简历</text>
<view class="g6__plate g6__plate--blue">
<image class="sk-ico" :src="ICONS.resume" mode="aspectFit" />
</view>
</view>
<view class="g6__div" />
<view class="g6__row">
<view class="g6__rt">
面试·交流区
</view>
<text class="g6__rd">扒一扒Hr的话术</text>
<view class="g6__plate g6__plate--green">
<image class="sk-ico" :src="ICONS.gradcap" mode="aspectFit" />
</view>
</view>
<view class="g6__div" />
<view class="g6__foot">
<view class="g6__avas">
<view class="sk-ava g6__ava1" />
<view class="sk-ava g6__ava2" />
<view class="sk-ava g6__ava3" />
</view>
<text class="g6__ft">1084 位导师在线 ›</text>
</view>
</view>
</view>
</view>
<!-- 07 蒙面约会 / 找个对象 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">07</text>
<text class="sk-cap__name">约会双卡 · 渐变胶囊</text>
</view>
<view class="sk-row">
<view class="sk-card sk-grad-purple g7">
<text class="g7__tt">蒙面约会</text>
<text class="g7__td">约会"0"压力</text>
<image class="g7__ico" :src="ICONS.mask" mode="aspectFit" />
</view>
<view class="sk-card sk-grad-pink g7">
<text class="g7__tt">找个对象</text>
<text class="g7__td">开启甜蜜互动</text>
<image class="g7__ico" :src="ICONS.community" mode="aspectFit" />
</view>
</view>
</view>
</view>
</template>
<!-- H5 端 uni 会给页面级非 scoped 样式也加 data-v,穿不透子组件;
因此共享原子类需在本组件再引一次(各自 scope 内生效,MP 端重复仅数 KB)。 -->
<style lang="scss">
@import '../style/showcase.scss';
</style>
<style scoped>
.wrap {
display: flex;
flex-direction: column;
}
/* ===== 01 欢迎卡 ===== */
.g1 {
padding: 28rpx;
border-radius: 30rpx;
background: linear-gradient(180deg, #ffffff 0%, #eef0ff 55%, #dde3ff 100%);
}
.g1__top {
display: flex;
align-items: center;
gap: 20rpx;
}
.g1__bot {
flex-shrink: 0;
width: 110rpx;
height: 110rpx;
}
.g1__hello {
display: flex;
flex-direction: column;
gap: 8rpx;
min-width: 0;
}
.g1__hi {
font-size: 30rpx;
font-weight: 800;
color: #2b2350;
}
.g1__ask {
font-size: 20rpx;
color: #8b87a8;
}
.g1__banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin: 22rpx 0;
padding: 12rpx 12rpx 12rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #cfe0ff, #ddd0ff);
}
.g1__bt {
flex: 1;
min-width: 0;
font-size: 20rpx;
color: #4a4470;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g1__sub {
flex: 1;
min-width: 0;
padding: 20rpx 18rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
}
.g1__sico {
width: 76rpx;
height: 76rpx;
}
.g1__st {
margin-top: 14rpx;
font-size: 26rpx;
font-weight: 800;
color: #fff;
}
.g1__sd {
margin-top: 6rpx;
font-size: 18rpx;
line-height: 1.4;
color: rgba(255, 255, 255, 0.82);
}
.g1__chip {
align-self: flex-start;
margin-top: 14rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.22);
font-size: 17rpx;
color: #fff;
}
/* ===== 02 美食专区 ===== */
.g2 {
position: relative;
overflow: hidden;
padding: 28rpx;
border-radius: 30rpx;
background: linear-gradient(160deg, #eef75e 0%, #9fe660 55%, #4fd06c 100%);
}
.g2__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.g2__ht {
font-size: 32rpx;
font-weight: 900;
color: #173d22;
}
.g2__body {
display: flex;
gap: 18rpx;
margin-top: 20rpx;
}
.g2__left {
position: relative;
flex: 1.15;
min-width: 0;
display: flex;
flex-direction: column;
}
.g2__lt {
font-size: 28rpx;
font-weight: 800;
color: #173d22;
}
.g2__ld {
margin-top: 6rpx;
font-size: 19rpx;
color: rgba(23, 61, 34, 0.72);
}
.g2__price {
display: flex;
align-items: baseline;
gap: 10rpx;
margin-top: 14rpx;
}
.g2__pl {
font-size: 19rpx;
color: #173d22;
font-weight: 600;
}
.g2__pv {
font-size: 46rpx;
font-weight: 900;
color: #ff8a1e;
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.55);
}
.g2__btn {
align-self: flex-start;
margin-top: 18rpx;
padding: 10rpx 30rpx;
border-radius: 999rpx;
background: #eaff7a;
font-size: 24rpx;
font-weight: 800;
color: #173d22;
box-shadow: 0 6rpx 14rpx rgba(23, 61, 34, 0.18);
}
.g2__fruit {
position: absolute;
right: -6rpx;
bottom: 8rpx;
width: 170rpx;
height: 170rpx;
}
.g2__ghost {
font-size: 60rpx;
}
.g2__right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.g2__sm {
position: relative;
overflow: hidden;
flex: 1;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.78);
}
.g2__smt {
font-size: 25rpx;
font-weight: 800;
color: #173d22;
}
.g2__smd {
display: block;
margin-top: 6rpx;
font-size: 18rpx;
color: rgba(23, 61, 34, 0.6);
}
.g2__smi {
position: absolute;
right: 6rpx;
top: 50%;
width: 88rpx;
height: 88rpx;
transform: translateY(-50%);
}
.g2__ghost--sm {
font-size: 40rpx;
bottom: -2rpx;
}
/* ===== 03 航班卡 ===== */
.g3 {
display: flex;
gap: 16rpx;
padding: 20rpx;
border-radius: 30rpx;
background: linear-gradient(165deg, #5aa8ff, #2e6be6);
}
.g3__main {
position: relative;
overflow: hidden;
flex: 1.1;
min-width: 0;
padding: 22rpx;
border-radius: 24rpx;
background: #fff;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.g3__mt {
font-size: 28rpx;
font-weight: 800;
color: #1d2b50;
}
.g3__md {
margin-top: 8rpx;
font-size: 19rpx;
color: #8b95b5;
}
.g3__set {
margin-top: auto;
padding: 8rpx 22rpx;
border-radius: 999rpx;
border: 1rpx solid #d6def0;
font-size: 20rpx;
color: #44507a;
}
.g3__plane {
position: absolute;
right: -20rpx;
bottom: -8rpx;
width: 190rpx;
height: 190rpx;
}
.g3__col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.g3__item {
position: relative;
overflow: hidden;
flex: 1;
padding: 22rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.16);
}
.g3__itt {
font-size: 26rpx;
font-weight: 800;
color: #fff;
}
.g3__itd {
display: block;
margin-top: 8rpx;
font-size: 19rpx;
color: rgba(255, 255, 255, 0.8);
}
.g3__iti {
position: absolute;
right: 8rpx;
bottom: 4rpx;
width: 92rpx;
height: 92rpx;
}
/* ===== 04 更多活动 ===== */
.g4 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.g4__cell {
position: relative;
overflow: hidden;
min-height: 168rpx;
padding: 18rpx 20rpx;
border-radius: 24rpx;
box-sizing: border-box;
}
.act--violet { background: linear-gradient(160deg, #e9e6ff, #d3d0ff); }
.act--mint { background: linear-gradient(160deg, #ddf6e6, #c4efD4); }
.act--peach { background: linear-gradient(160deg, #fff0d8, #ffe3ba); }
.act--rose { background: linear-gradient(160deg, #ffe5ee, #ffd3e1); }
.g4__tag {
align-self: flex-start;
display: inline-block;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
font-size: 16rpx;
font-weight: 700;
}
.act--violet .g4__tag { color: #6c5ce7; }
.act--mint .g4__tag { color: #27a05a; }
.act--peach .g4__tag { color: #e8890c; }
.act--rose .g4__tag { color: #ef5b8d; }
.g4__tt {
display: block;
margin-top: 12rpx;
font-size: 27rpx;
font-weight: 800;
}
.act--violet .g4__tt { color: #5a4bd4; }
.act--mint .g4__tt { color: #27a05a; }
.act--peach .g4__tt { color: #f2994a; }
.act--rose .g4__tt { color: #f2637f; }
.g4__td {
display: block;
margin-top: 4rpx;
font-size: 18rpx;
color: rgba(60, 50, 40, 0.55);
}
.g4__arr {
position: absolute;
left: 20rpx;
bottom: 16rpx;
}
.g4__ico {
position: absolute;
right: 4rpx;
bottom: 6rpx;
width: 104rpx;
height: 104rpx;
}
/* ===== 05 文件夹九宫格 ===== */
.g5 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.g5__cell {
position: relative;
overflow: hidden;
min-height: 150rpx;
padding: 20rpx;
border-radius: 24rpx;
box-sizing: border-box;
}
.file--blue { background: linear-gradient(160deg, #e0ebff, #c6dcff); }
.file--teal { background: linear-gradient(160deg, #d9f6ec, #bdefd9); }
.file--orange { background: linear-gradient(160deg, #ffead4, #ffdcb8); }
.file--indigo { background: linear-gradient(160deg, #e2e3ff, #cbceff); }
.file--pink { background: linear-gradient(160deg, #ffe2ee, #ffd2e2); }
.file--yellow { background: linear-gradient(160deg, #fff4cd, #ffebA6); }
.g5__tt {
display: block;
font-size: 26rpx;
font-weight: 800;
color: #333852;
}
.g5__td {
display: block;
margin-top: 6rpx;
font-size: 18rpx;
color: rgba(51, 56, 82, 0.5);
}
.g5__arr {
position: absolute;
left: 20rpx;
bottom: 14rpx;
}
.g5__ico {
position: absolute;
right: -6rpx;
top: 50%;
width: 118rpx;
height: 118rpx;
transform: translateY(-50%);
}
/* ===== 06 Hi~康笑 ===== */
.g6 {
display: flex;
gap: 20rpx;
}
.g6__left {
flex: 1.05;
min-width: 0;
display: flex;
flex-direction: column;
}
.g6__hi {
font-size: 30rpx;
font-weight: 800;
color: #26221c;
}
.g6__mid {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 18rpx;
}
.g6__ava {
flex-shrink: 0;
width: 128rpx;
height: 128rpx;
border-radius: 24rpx;
background: #f4ede6;
}
.g6__quote {
position: relative;
min-width: 0;
padding: 0 8rpx;
}
.g6__qmark {
position: absolute;
left: -6rpx;
top: -18rpx;
font-size: 44rpx;
font-weight: 900;
color: #d8d2ca;
}
.g6__qmark--end {
left: auto;
right: -6rpx;
top: auto;
bottom: -26rpx;
}
.g6__qt {
display: block;
font-size: 22rpx;
line-height: 1.5;
color: #6f6659;
}
.g6__btn {
align-self: flex-start;
margin-top: 20rpx;
background: #4a90e8;
color: #fff;
}
.g6__right {
flex: 1;
min-width: 0;
padding: 20rpx;
border: 1rpx solid #efe9e2;
border-radius: 24rpx;
}
.g6__row {
position: relative;
padding-right: 88rpx;
min-height: 88rpx;
}
.g6__rt {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 25rpx;
font-weight: 800;
color: #26221c;
}
.g6__hot {
padding: 1rpx 10rpx;
border-radius: 999rpx;
background: #ff4d4f;
color: #fff;
font-size: 15rpx;
font-weight: 700;
}
.g6__rd {
display: block;
margin-top: 6rpx;
font-size: 18rpx;
color: #9a9083;
}
.g6__plate {
position: absolute;
right: 0;
top: 50%;
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
padding: 12rpx;
box-sizing: border-box;
transform: translateY(-50%);
}
.g6__plate--blue { background: linear-gradient(160deg, #5aa8ff, #2e6be6); }
.g6__plate--green { background: linear-gradient(160deg, #7de3a2, #35b96e); }
.g6__div {
height: 1rpx;
margin: 16rpx 0;
background: #f1ebe4;
}
.g6__foot {
display: flex;
align-items: center;
gap: 12rpx;
}
.g6__avas {
display: flex;
}
.g6__ava1 { background: linear-gradient(150deg, #ffb257, #fb6a14); }
.g6__ava2 { background: linear-gradient(150deg, #67d5e8, #2ea8d8); }
.g6__ava3 { background: linear-gradient(150deg, #a98bff, #7b5bf0); }
.g6__ft {
font-size: 19rpx;
color: #9a9083;
}
/* ===== 07 约会双卡 ===== */
.g7 {
flex: 1;
min-height: 150rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.g7__tt {
font-size: 27rpx;
font-weight: 800;
color: #fff;
}
.g7__td {
margin-top: 6rpx;
font-size: 19rpx;
color: rgba(255, 255, 255, 0.85);
}
.g7__ico {
position: absolute;
right: 10rpx;
top: 50%;
width: 104rpx;
height: 104rpx;
transform: translateY(-50%);
}
</style>

View File

@@ -0,0 +1,508 @@
<script setup lang="ts">
/**
* 图三板块:复刻参考图三的 6 组卡片。
*
* 15 成就徽章 16 电台宫格 17 夸克复习大卡
* 18 美食 2×2 19 找院校/找专业 20 企微 2×2
*/
import { ICONS } from '../icons'
/** 15 成就徽章 */
const badges = [
{ key: 'badge-star', title: '学习之星', cls: 'bd--gold', bar: 0 },
{ key: 'badge-bolt', title: '速度达人', cls: 'bd--blue', bar: 0 },
{ key: 'badge-heart', title: '社交大师', cls: 'bd--pink', bar: 62 },
{ key: 'badge-crown', title: '挑战达人', cls: 'bd--orange', bar: 0 },
] as const
/** 16 电台宫格 */
const radios = [
{ key: 'vinyl', title: '在线电台', num: '2352', cls: 'rd--violet' },
{ key: 'chatdots', title: '交友大区', num: '5026', cls: 'rd--gold' },
{ key: 'cameraretro', title: '社区交友', num: '3587', cls: 'rd--orange' },
{ key: 'hearts', title: '心动速配', num: '7856', cls: 'rd--pink' },
] as const
/** 18 美食 2×2 */
const foods = [
{ key: 'burger', title: '汉堡包', en: 'HAMBURGER', cls: 'fd--amber' },
{ key: 'cake', title: '草莓蛋糕', en: 'STRAWBERRY CAKE', cls: 'fd--pink' },
{ key: 'donut', title: '甜甜圈', en: 'DONUT', cls: 'fd--lilac' },
{ key: 'coffee', title: '现磨咖啡', en: 'FRESH GROUND COFFEE', cls: 'fd--brown' },
] as const
/** 20 企微 2×2 */
const biz = [
{ key: 'wework', title: '企微助手', tag: '特别加推', desc: '全新全面路感,致力于私域增长' },
{ key: 'store', title: '智慧零售', tag: '电商专场', desc: '要店线下打通,重构消费新体验' },
{ key: 'cart', title: '微商城', tag: '电商营销', desc: '小程序+公众号一体化经营商城' },
{ key: 'dinner', title: '智慧餐厅', tag: '眉外引流通', desc: '智慧服务业,服务外卖双通道' },
] as const
</script>
<template>
<view class="wrap">
<!-- 15 成就徽章 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">15</text>
<text class="sk-cap__name">成就徽章 · 2×2 淡彩</text>
</view>
<view class="g15">
<view v-for="b in badges" :key="b.key" class="g15__cell" :class="b.cls">
<text class="g15__tt">{{ b.title }}</text>
<text class="g15__td">坚持打卡 35day</text>
<text class="g15__dc">比昨日提升一次小目标!</text>
<view v-if="b.bar > 0" class="sk-bar g15__bar">
<view class="sk-bar__in" :style="{ width: b.bar + '%' }" />
</view>
<image class="g15__ico" :src="ICONS[b.key]" mode="aspectFit" />
</view>
</view>
</view>
<!-- 16 电台宫格 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">16</text>
<text class="sk-cap__name">电台卡 · 2×2 彩底</text>
</view>
<view class="g16">
<view v-for="r in radios" :key="r.key" class="g16__cell" :class="r.cls">
<image class="g16__ico" :src="ICONS[r.key]" mode="aspectFit" />
<text class="g16__tt">{{ r.title }}</text>
<view class="sk-people g16__pp">
<text>👤 {{ r.num }}</text>
</view>
</view>
</view>
</view>
<!-- 17 夸克复习大卡 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">17</text>
<text class="sk-cap__name">复习大卡 · 缎带 + 分栏</text>
</view>
<view class="g17">
<view class="sk-ribbon g17__ribbon">
夸克陪你·高效复习
</view>
<view class="sk-row g17__mid">
<view class="g17__half g17__half--a">
<image class="g17__ico" :src="ICONS.docscheck" mode="aspectFit" />
<text class="g17__tt">历年真题题库</text>
<text class="g17__td">命制真题·针对性最强</text>
<view class="g17__btns">
<view class="sk-pill sk-pill--sm g17__pbtn">
公共课
</view>
<view class="sk-pill sk-pill--sm g17__pbtn">
专业课
</view>
</view>
</view>
<view class="g17__half g17__half--b">
<image class="g17__ico" :src="ICONS.scan" mode="aspectFit" />
<text class="g17__tt">拍题搜解析</text>
<text class="g17__td">触题即搜,一拍就有解</text>
<view class="sk-pill sk-pill--sm g17__pbtn g17__pbtn--wide">
去拍题
</view>
</view>
</view>
<view class="g17__foot">
<image class="g17__fico" :src="ICONS.wordbook" mode="aspectFit" />
<view class="g17__ft">
<text class="g17__ftt">背单词神器</text>
<text class="g17__ftd">考研词汇,轻松牢记</text>
</view>
<view class="sk-pill sk-pill--sm g17__pbtn g17__pbtn--green">
背单词
</view>
</view>
</view>
</view>
<!-- 18 美食 2×2 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">18</text>
<text class="sk-cap__name">美食卡 · 圆洞底座</text>
</view>
<view class="g18">
<view v-for="f in foods" :key="f.key" class="g18__cell" :class="f.cls">
<view class="g18__hole">
<image class="g18__ico" :src="ICONS[f.key]" mode="aspectFit" />
</view>
<text class="g18__tt">{{ f.title }}</text>
<text class="g18__en">{{ f.en }}</text>
</view>
</view>
</view>
<!-- 19 找院校 / 找专业 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">19</text>
<text class="sk-cap__name">快捷入口 · 双格白卡</text>
</view>
<view class="sk-row">
<view class="g19">
<text class="g19__tt">找院校 ›</text>
<text class="g19__td">24 年最新信息</text>
<view class="sk-pill sk-pill--sm g19__pill g19__pill--blue">
查排名
</view>
<image class="g19__ico" :src="ICONS.college" mode="aspectFit" />
</view>
<view class="g19">
<text class="g19__tt">找专业 ›</text>
<text class="g19__td">比较同专业院校</text>
<view class="sk-pill sk-pill--sm g19__pill g19__pill--orange">
看门道
</view>
<image class="g19__ico" :src="ICONS.majorbag" mode="aspectFit" />
</view>
</view>
</view>
<!-- 20 企微 2×2 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">20</text>
<text class="sk-cap__name">企微卡 · 淡紫 2×2</text>
</view>
<view class="g20">
<view v-for="b in biz" :key="b.key" class="g20__cell">
<view class="g20__head">
<text class="g20__tt">{{ b.title }}</text>
<text class="g20__tag">{{ b.tag }}</text>
</view>
<text class="g20__td">{{ b.desc }}</text>
<image class="g20__ico" :src="ICONS[b.key]" mode="aspectFit" />
</view>
</view>
</view>
</view>
</template>
<style lang="scss">
@import '../style/showcase.scss';
</style>
<style scoped>
.wrap {
display: flex;
flex-direction: column;
}
/* ===== 15 成就徽章 ===== */
.g15 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.g15__cell {
position: relative;
overflow: hidden;
min-height: 170rpx;
padding: 20rpx;
border-radius: 22rpx;
box-sizing: border-box;
}
.bd--gold { background: linear-gradient(160deg, #fff6d8, #ffe9a8); }
.bd--blue { background: linear-gradient(160deg, #e3f0ff, #c8e2ff); }
.bd--pink { background: linear-gradient(160deg, #ffe5ee, #ffd0e0); }
.bd--orange { background: linear-gradient(160deg, #fff0d8, #ffddb0); }
.g15__tt {
display: block;
font-size: 27rpx;
font-weight: 800;
color: #4a3b28;
}
.g15__td {
display: block;
margin-top: 8rpx;
font-size: 18rpx;
color: rgba(74, 59, 40, 0.72);
font-weight: 600;
}
.g15__dc {
display: block;
margin-top: 4rpx;
font-size: 15rpx;
color: rgba(74, 59, 40, 0.45);
}
.g15__bar {
margin-top: 12rpx;
}
.g15__ico {
position: absolute;
right: 8rpx;
top: 50%;
width: 110rpx;
height: 110rpx;
transform: translateY(-50%);
}
/* ===== 16 电台宫格 ===== */
.g16 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.g16__cell {
min-height: 200rpx;
padding: 22rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.rd--violet { background: linear-gradient(155deg, #b28cff, #8256e8); }
.rd--gold { background: linear-gradient(155deg, #ffd457, #f5a614); }
.rd--orange { background: linear-gradient(155deg, #ff9d68, #f2633f); }
.rd--pink { background: linear-gradient(155deg, #ff9fc0, #ef5b8d); }
.g16__ico {
width: 104rpx;
height: 104rpx;
margin-top: 4rpx;
}
.g16__tt {
margin-top: auto;
font-size: 27rpx;
font-weight: 800;
color: #fff;
}
.g16__pp {
margin-top: 4rpx;
color: rgba(255, 255, 255, 0.85);
}
/* ===== 17 夸克复习大卡 ===== */
.g17 {
display: flex;
flex-direction: column;
padding: 24rpx;
border-radius: 30rpx;
background: linear-gradient(170deg, #d8f0ff 0%, #eaf6ff 45%, #f7fbff 100%);
}
.g17__ribbon {
margin-top: -4rpx;
}
.g17__mid {
margin-top: 20rpx;
gap: 16rpx;
}
.g17__half {
flex: 1;
min-width: 0;
min-height: 240rpx;
padding: 20rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
box-sizing: border-box;
}
.g17__half--a { background: linear-gradient(160deg, #ffe9d8, #ffd6b8); }
.g17__half--b { background: linear-gradient(160deg, #d8e6ff, #bcd2ff); }
.g17__ico {
width: 96rpx;
height: 96rpx;
}
.g17__tt {
margin-top: 12rpx;
font-size: 25rpx;
font-weight: 800;
color: #33261a;
}
.g17__td {
margin-top: 4rpx;
font-size: 17rpx;
color: rgba(51, 38, 26, 0.55);
}
.g17__btns {
display: flex;
gap: 10rpx;
margin-top: auto;
}
.g17__pbtn {
margin-top: 14rpx;
background: linear-gradient(150deg, #ffb257, #fb6a14);
color: #fff;
}
.g17__pbtn--wide {
margin-top: auto;
background: #4a90e8;
padding: 0 34rpx;
}
.g17__pbtn--green {
background: #35b96e;
color: #fff;
}
.g17__foot {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 16rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: linear-gradient(160deg, #ddf7e6, #c4efd4);
}
.g17__fico {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
}
.g17__ft {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.g17__ftt {
font-size: 24rpx;
font-weight: 800;
color: #1c4d2a;
}
.g17__ftd {
margin-top: 2rpx;
font-size: 17rpx;
color: rgba(28, 77, 42, 0.6);
}
.g17__foot .g17__pbtn {
margin-top: 0;
}
/* ===== 18 美食 2×2 ===== */
.g18 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.g18__cell {
min-height: 190rpx;
padding: 22rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
box-sizing: border-box;
}
.fd--amber { background: linear-gradient(155deg, #ffd98a, #f5b84d); }
.fd--pink { background: linear-gradient(155deg, #ffc9d9, #f59ab5); }
.fd--lilac { background: linear-gradient(155deg, #d9c2ff, #b394eb); }
.fd--brown { background: linear-gradient(155deg, #d4a97a, #a9764a); }
.g18__hole {
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.14);
padding: 6rpx;
box-sizing: border-box;
}
.g18__ico {
width: 100%;
height: 100%;
}
.g18__tt {
margin-top: 12rpx;
font-size: 25rpx;
font-weight: 800;
color: #fff;
}
.g18__en {
margin-top: 2rpx;
font-size: 14rpx;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.7);
}
/* ===== 19 找院校 / 找专业 ===== */
.g19 {
position: relative;
overflow: hidden;
flex: 1;
min-width: 0;
min-height: 170rpx;
padding: 22rpx;
border-radius: 24rpx;
border: 1rpx solid #f0eae2;
background: #fffdf9;
box-sizing: border-box;
}
.g19__tt {
font-size: 28rpx;
font-weight: 800;
color: #26221c;
}
.g19__td {
display: block;
margin-top: 6rpx;
font-size: 18rpx;
color: #9a9083;
}
.g19__pill {
margin-top: 14rpx;
}
.g19__pill--blue { background: #4a90e8; color: #fff; }
.g19__pill--orange { background: #fb8a14; color: #fff; }
.g19__ico {
position: absolute;
right: 6rpx;
bottom: 6rpx;
width: 100rpx;
height: 100rpx;
}
/* ===== 20 企微 2×2 ===== */
.g20 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.g20__cell {
position: relative;
overflow: hidden;
min-height: 150rpx;
padding: 18rpx;
border-radius: 20rpx;
background: linear-gradient(160deg, #f3efff, #e9e2ff);
box-sizing: border-box;
}
.g20__head {
display: flex;
align-items: center;
gap: 8rpx;
}
.g20__tt {
font-size: 23rpx;
font-weight: 800;
color: #3a2e66;
}
.g20__tag {
padding: 1rpx 8rpx;
border-radius: 999rpx;
background: rgba(123, 91, 240, 0.14);
font-size: 13rpx;
color: #7b5bf0;
white-space: nowrap;
}
.g20__td {
display: block;
margin-top: 6rpx;
font-size: 15rpx;
line-height: 1.4;
color: rgba(58, 46, 102, 0.55);
}
.g20__ico {
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 74rpx;
height: 74rpx;
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,664 @@
<script setup lang="ts">
/**
* 图二板块:复刻参考图二的 7 组功能卡片拼贴。
*
* 08 开始创作 09 组卷三列 10 橙蓝双卡 11 情侣闺蜜基友
* 12 课程拼贴 13 任务台 14 测评宫格
*/
import { ICONS } from '../icons'
/** 09 组卷三列 */
const papers3 = [
{ key: 'papers', title: '自适应组卷' },
{ key: 'selfbook', title: '自主备考' },
{ key: 'exampaper', title: '真题好卷' },
] as const
/** 11 情侣/闺蜜/基友 */
const pals = [
{ key: 'lovepop', title: '情侣', desc: '我们让相遇刚好在权利遇到爱', cls: 'sk-grad-pink' },
{ key: 'icepop', title: '闺蜜', desc: '我们用刚刚好的权利遇到爱', cls: 'sk-grad-orange' },
{ key: 'broballoon', title: '基友', desc: '我们亲如手足肝胆相照', cls: 'sk-grad-blue' },
] as const
/** 14 测评宫格 2×3 */
const tests = [
{ key: 'compass', tag: '趣味测评', title: '堂兰量兴趣测评', cls: 'tst--orange' },
{ key: 'persona', tag: '人格测评', title: 'MBTI 测评', cls: 'tst--blue' },
{ key: 'brain', tag: '专业测评', title: 'SCL90 测试', cls: 'tst--purple' },
{ key: 'briefcase', tag: '职业发展', title: '职业性格测评', cls: 'tst--green' },
{ key: 'atom', tag: '潜能测评', title: '多元智能测评', cls: 'tst--teal' },
{ key: 'anchor', tag: '职业锚', title: '职业锚测评', cls: 'tst--navy' },
] as const
</script>
<template>
<view class="wrap">
<!-- 08 开始创作 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">08</text>
<text class="sk-cap__name">开始创作 · 紫渐变大卡</text>
</view>
<view class="sk-card sk-grad-purple g8">
<view class="g8__left">
<view class="g8__head">
<view class="g8__plus">
+
</view>
<view class="g8__ht">
<text class="g8__tt">开始创作</text>
<text class="g8__td">开启你的灵感之旅</text>
</view>
</view>
<view class="g8__draft">
<text class="g8__dl">草稿箱</text>
<text class="g8__dn">28</text>
<view class="g8__avas">
<view class="sk-ava g8__ava1" />
<view class="sk-ava g8__ava2" />
<view class="sk-ava g8__ava3" />
</view>
<text class="g8__more">›</text>
</view>
</view>
<view class="g8__right">
<view class="g8__live">
<view class="g8__lava">
<image class="sk-ico" :src="ICONS['avatar-woman']" mode="aspectFit" />
</view>
<view class="g8__lt">
<text class="g8__ltt">推荐直播·</text>
<text class="g8__ltd">30+达人正在直播</text>
</view>
</view>
<view class="sk-row">
<view class="g8__mini">
<image class="g8__mi" :src="ICONS.script" mode="aspectFit" />
<text class="g8__mt">剧本创作 ›</text>
</view>
<view class="g8__mini">
<image class="g8__mi" :src="ICONS.chart" mode="aspectFit" />
<text class="g8__mt">数据排行 ›</text>
</view>
</view>
</view>
</view>
</view>
<!-- 09 组卷三列 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">09</text>
<text class="sk-cap__name">组卷卡 · 三列瓷片</text>
</view>
<view class="sk-grid3">
<view v-for="p in papers3" :key="p.key" class="g9">
<image class="g9__ico" :src="ICONS[p.key]" mode="aspectFit" />
<text class="g9__tt">{{ p.title }}</text>
<view class="sk-pill sk-pill--sm g9__btn">
进入
</view>
</view>
</view>
</view>
<!-- 10 橙蓝双卡 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">10</text>
<text class="sk-cap__name">练习卡 · 橙蓝双拼</text>
</view>
<view class="sk-row">
<view class="sk-card sk-grad-orange g10">
<view class="g10__tile">
á
</view>
<text class="g10__tt">个性化快速练习</text>
<view class="sk-pill sk-pill--white sk-pill--sm g10__btn">
快速测试 ›
</view>
</view>
<view class="sk-card sk-grad-blue g10">
<image class="g10__ico" :src="ICONS.mic" mode="aspectFit" />
<text class="g10__tt">考试原题测试</text>
<view class="sk-pill sk-pill--white sk-pill--sm g10__btn">
原题测试 ›
</view>
</view>
</view>
</view>
<!-- 11 情侣 / 闺蜜 / 基友 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">11</text>
<text class="sk-cap__name">邀请卡 · 三竖版渐变</text>
</view>
<view class="sk-row">
<view v-for="p in pals" :key="p.key" class="sk-card g11" :class="p.cls">
<image class="g11__ico" :src="ICONS[p.key]" mode="aspectFit" />
<text class="g11__tt">{{ p.title }}</text>
<text class="g11__td">{{ p.desc }}</text>
<view class="sk-pill sk-pill--sm g11__btn">
邀请
</view>
</view>
</view>
</view>
<!-- 12 课程拼贴 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">12</text>
<text class="sk-cap__name">课程卡 · 深底马赛克</text>
</view>
<view class="g12">
<view class="sk-grid2 g12__grid">
<view class="sk-col">
<view class="sk-card g12__cell g12__red">
<text class="g12__tt">预习上课</text>
<text class="g12__td">这里是简要标题</text>
</view>
<view class="sk-card g12__cell g12__violet">
<text class="g12__tt">复习</text>
<text class="g12__td">这里是简要标题</text>
<image class="g12__ico g12__ico--ring" :src="ICONS.donut" mode="aspectFit" />
</view>
<view class="sk-card g12__cell g12__tall g12__gold">
<text class="g12__tt">完成作业</text>
<text class="g12__td">这里是简要标题</text>
<image class="g12__ico g12__ico--book" :src="ICONS.bookA" mode="aspectFit" />
</view>
</view>
<view class="sk-col">
<view class="sk-card g12__cell g12__tall g12__blue">
<text class="g12__tt">课堂互动</text>
<text class="g12__td">这里是简要标题</text>
<image class="g12__ico g12__ico--bell" :src="ICONS.bell" mode="aspectFit" />
<image class="g12__ico g12__ico--chat" :src="ICONS.chat" mode="aspectFit" />
</view>
<view class="sk-card g12__cell g12__white">
<text class="g12__tt g12__tt--dark">错题本</text>
<text class="g12__td g12__td--dark">这里是简要标题</text>
<image class="g12__ico g12__ico--notes" :src="ICONS.notes" mode="aspectFit" />
</view>
<view class="sk-card g12__cell g12__green">
<text class="g12__tt">笔记本</text>
<text class="g12__td">这里是简要标题</text>
<image class="g12__ico g12__ico--pencil" :src="ICONS.pencil" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
<!-- 13 任务台 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">13</text>
<text class="sk-cap__name">任务台 · 横滑卡组</text>
</view>
<view class="g13">
<view class="g13__left">
<text class="g13__en">TASKDESK</text>
<text class="g13__tt">任务台</text>
<view class="sk-pill sk-pill--sm g13__btn">
立即查看 ›
</view>
</view>
<view class="g13__scroll">
<view class="sk-card sk-grad-purple g13__card">
<view class="g13__badge">
无需资质·快变现
</view>
<image class="g13__ico" :src="ICONS.miniapp" mode="aspectFit" />
<text class="g13__ct">小程序</text>
<text class="g13__cd">无需资质·快变现</text>
</view>
<view class="sk-card sk-grad-pink g13__card">
<view class="g13__badge">
影视漫四·新流量
</view>
<image class="g13__ico" :src="ICONS.film" mode="aspectFit" />
<text class="g13__ct">影视漫画</text>
<text class="g13__cd">三大平台·丰富内容</text>
</view>
<view class="sk-card sk-grad-cyan g13__card">
<view class="g13__badge">
效率工具·高复购
</view>
<image class="g13__ico" :src="ICONS.miniapp" mode="aspectFit" />
<text class="g13__ct">效率工具</text>
<text class="g13__cd">即拿即用·轻松上手</text>
</view>
</view>
</view>
</view>
<!-- 14 测评宫格 -->
<view class="sk-sec">
<view class="sk-cap">
<text class="sk-cap__no">14</text>
<text class="sk-cap__name">测评卡 · 2×3 细格</text>
</view>
<view class="g14">
<view v-for="t in tests" :key="t.key" class="g14__cell">
<view class="g14__tag" :class="t.cls">
{{ t.tag }}
</view>
<text class="g14__tt">{{ t.title }}</text>
<image class="g14__ico" :src="ICONS[t.key]" mode="aspectFit" />
</view>
</view>
</view>
</view>
</template>
<style lang="scss">
@import '../style/showcase.scss';
</style>
<style scoped>
.wrap {
display: flex;
flex-direction: column;
}
/* ===== 08 开始创作 ===== */
.g8 {
display: flex;
gap: 20rpx;
padding: 26rpx;
border-radius: 30rpx;
}
.g8__left {
flex: 1.1;
min-width: 0;
display: flex;
flex-direction: column;
}
.g8__head {
display: flex;
align-items: center;
gap: 16rpx;
}
.g8__plus {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.24);
color: #fff;
font-size: 40rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.g8__ht {
display: flex;
flex-direction: column;
min-width: 0;
}
.g8__tt {
font-size: 30rpx;
font-weight: 800;
color: #fff;
}
.g8__td {
margin-top: 4rpx;
font-size: 19rpx;
color: rgba(255, 255, 255, 0.8);
}
.g8__draft {
display: flex;
align-items: center;
gap: 10rpx;
margin-top: auto;
padding-top: 26rpx;
}
.g8__dl {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
}
.g8__dn {
font-size: 34rpx;
font-weight: 900;
color: #fff;
}
.g8__avas {
display: flex;
margin-left: 6rpx;
}
.g8__ava1 { background: linear-gradient(150deg, #ffb257, #fb6a14); }
.g8__ava2 { background: linear-gradient(150deg, #67d5e8, #2ea8d8); }
.g8__ava3 { background: linear-gradient(150deg, #ffd2e0, #f2637f); }
.g8__more {
color: rgba(255, 255, 255, 0.8);
font-size: 26rpx;
}
.g8__right {
flex: 1;
min-width: 0;
padding: 18rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.14);
display: flex;
flex-direction: column;
gap: 16rpx;
}
.g8__live {
display: flex;
align-items: center;
gap: 12rpx;
}
.g8__lava {
flex-shrink: 0;
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
padding: 6rpx;
box-sizing: border-box;
}
.g8__lt {
display: flex;
flex-direction: column;
min-width: 0;
}
.g8__ltt {
font-size: 22rpx;
font-weight: 700;
color: #fff;
}
.g8__ltd {
margin-top: 2rpx;
font-size: 17rpx;
color: rgba(255, 255, 255, 0.72);
}
.g8__mini {
flex: 1;
min-width: 0;
padding: 14rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.14);
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.g8__mi {
width: 56rpx;
height: 56rpx;
}
.g8__mt {
font-size: 18rpx;
color: #fff;
white-space: nowrap;
}
/* ===== 09 组卷三列 ===== */
.g9 {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 12rpx 18rpx;
border-radius: 22rpx;
background: #f7f9ff;
}
.g9__ico {
width: 100rpx;
height: 100rpx;
}
.g9__tt {
margin-top: 10rpx;
font-size: 22rpx;
font-weight: 700;
color: #333852;
}
.g9__btn {
margin-top: 12rpx;
background: linear-gradient(90deg, #67d5e8, #2ea8d8);
color: #fff;
}
/* ===== 10 橙蓝双卡 ===== */
.g10 {
flex: 1;
min-width: 0;
min-height: 200rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.g10__tile {
width: 88rpx;
height: 88rpx;
border-radius: 22rpx;
background: #fff;
color: #fb6a14;
font-size: 52rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 14rpx rgba(107, 47, 15, 0.18);
}
.g10__ico {
width: 88rpx;
height: 88rpx;
}
.g10__tt {
margin-top: 16rpx;
font-size: 26rpx;
font-weight: 800;
color: #fff;
}
.g10__btn {
margin-top: auto;
padding-top: 0;
}
.g10__btn {
margin-top: 16rpx;
}
/* ===== 11 三竖版邀请卡 ===== */
.g11 {
flex: 1;
min-width: 0;
min-height: 300rpx;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.g11__ico {
width: 96rpx;
height: 96rpx;
margin-top: 8rpx;
}
.g11__tt {
margin-top: 12rpx;
font-size: 28rpx;
font-weight: 800;
color: #fff;
}
.g11__td {
margin-top: 8rpx;
font-size: 17rpx;
line-height: 1.4;
color: rgba(255, 255, 255, 0.82);
}
.g11__btn {
margin-top: auto;
background: #fff;
color: #e8531a;
margin-bottom: 4rpx;
}
/* ===== 12 课程拼贴 ===== */
.g12 {
padding: 18rpx;
border-radius: 30rpx;
background: linear-gradient(165deg, #2a3a5e, #16203a);
}
.g12__grid {
gap: 14rpx;
}
.g12__cell {
min-height: 150rpx;
padding: 20rpx;
border-radius: 22rpx;
display: flex;
flex-direction: column;
}
.g12__tall {
min-height: 250rpx;
flex: 1;
}
.g12__red { background: linear-gradient(150deg, #ff7a68, #f0483c); }
.g12__violet { background: linear-gradient(150deg, #8f9bff, #6b5ce7); }
.g12__gold { background: linear-gradient(150deg, #ffc457, #fb9a14); }
.g12__blue { background: linear-gradient(150deg, #58a8ff, #2e6be6); }
.g12__green { background: linear-gradient(150deg, #7de3a2, #35b96e); }
.g12__white { background: #fff; }
.g12__tt {
font-size: 25rpx;
font-weight: 800;
color: #fff;
}
.g12__tt--dark { color: #26221c; }
.g12__td {
margin-top: 6rpx;
font-size: 17rpx;
color: rgba(255, 255, 255, 0.78);
}
.g12__td--dark { color: #9a9083; }
.g12__ico {
position: absolute;
width: 96rpx;
height: 96rpx;
}
.g12__ico--ring { right: 6rpx; bottom: 2rpx; }
.g12__ico--book { right: 10rpx; bottom: 8rpx; width: 120rpx; height: 120rpx; }
.g12__ico--bell { right: 10rpx; top: 18rpx; }
.g12__ico--chat { right: 14rpx; bottom: 10rpx; width: 120rpx; height: 120rpx; }
.g12__ico--notes { right: 8rpx; bottom: 4rpx; }
.g12__ico--pencil { right: 8rpx; bottom: 4rpx; }
/* ===== 13 任务台 ===== */
.g13 {
display: flex;
gap: 22rpx;
}
.g13__left {
flex-shrink: 0;
width: 180rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.g13__en {
font-size: 17rpx;
letter-spacing: 0.12em;
color: #b3a08e;
font-weight: 700;
}
.g13__tt {
margin-top: 6rpx;
font-size: 40rpx;
font-weight: 900;
color: #26221c;
}
.g13__btn {
margin-top: 18rpx;
background: #4a90e8;
color: #fff;
}
.g13__scroll {
flex: 1;
min-width: 0;
display: flex;
gap: 14rpx;
overflow: hidden;
}
.g13__card {
flex-shrink: 0;
width: 190rpx;
min-height: 190rpx;
padding: 16rpx;
border-radius: 22rpx;
display: flex;
flex-direction: column;
}
.g13__badge {
align-self: flex-start;
padding: 2rpx 10rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.26);
font-size: 14rpx;
color: #fff;
white-space: nowrap;
}
.g13__ico {
width: 64rpx;
height: 64rpx;
margin-top: 10rpx;
}
.g13__ct {
margin-top: auto;
font-size: 22rpx;
font-weight: 800;
color: #fff;
}
.g13__cd {
margin-top: 2rpx;
font-size: 14rpx;
color: rgba(255, 255, 255, 0.75);
}
/* ===== 14 测评宫格 ===== */
.g14 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
}
.g14__cell {
position: relative;
overflow: hidden;
min-height: 150rpx;
padding: 16rpx;
border-radius: 18rpx;
border: 1rpx solid #f0eae2;
background: #fffdf9;
box-sizing: border-box;
}
.g14__tag {
display: inline-block;
padding: 2rpx 10rpx;
border-radius: 999rpx;
font-size: 14rpx;
font-weight: 700;
}
.tst--orange { background: #fff1e0; color: #e8890c; }
.tst--blue { background: #e3f0ff; color: #2e6be6; }
.tst--purple { background: #efe9ff; color: #7b5bf0; }
.tst--green { background: #e2f7ea; color: #27a05a; }
.tst--teal { background: #dcf5f2; color: #14a08a; }
.tst--navy { background: #e5eaf5; color: #34518f; }
.g14__tt {
display: block;
margin-top: 12rpx;
font-size: 20rpx;
font-weight: 700;
color: #333852;
}
.g14__ico {
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 76rpx;
height: 76rpx;
}
</style>

134
src/pkg-style/showcase.vue Normal file
View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
/**
* 卡片样式库(分包 pkg-style)——复刻「瓷片区卡片设计」参考图 20 组卡片的展示页。
*
* 入口:FEATURE_ENTRIES(cardLibrary) → 「我的」页 cell 行 + 工作台「便捷功能」卡。
* 结构:自绘导航(heroPadTop 避让胶囊)+ 3 个板块(对应 3 张参考图)+ 底部图标体检格。
* 样式:共享 .sk-* 原子类在 style/showcase.scss,由本页非 scoped 块引入(穿透子组件)。
*/
import { heroPadTop } from '@/utils/safearea'
import { ICONS } from './icons'
import SecOne from './sections/SecOne.vue'
import SecThree from './sections/SecThree.vue'
import SecTwo from './sections/SecTwo.vue'
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<!-- 渐变头区:导航 -->
<view class="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 class="nav__ph" />
</view>
<view class="hero__sub">
<text>瓷片区卡片设计 · 20 组复刻</text>
</view>
</view>
<!-- 上浮内容区 -->
<view class="body">
<SecOne />
<SecTwo />
<SecThree />
<!-- 图标体检格:同一图标白底 / 橙底对照,检查白边与半透明瑕疵 -->
<view class="sk-sec">
<view class="sk-sec__title">
<view class="sk-sec__dot" />
<text>图标体检</text>
</view>
<view class="sk-check">
<view class="sk-check__cell sk-check__cell--w">
<image class="sk-ico" :src="ICONS['hero-bot']" mode="aspectFit" />
</view>
<view class="sk-check__cell sk-check__cell--o">
<image class="sk-ico" :src="ICONS['hero-bot']" mode="aspectFit" />
</view>
<view class="sk-check__cell sk-check__cell--w">
<image class="sk-ico" :src="ICONS.planet" mode="aspectFit" />
</view>
<view class="sk-check__cell sk-check__cell--o">
<image class="sk-ico" :src="ICONS.planet" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss">
@import './style/showcase.scss';
</style>
<style scoped>
.page {
min-height: 100vh;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
background: #fff9f2;
}
/* 头区:紫色渐变(呼应样式库主题色 violet) */
.hero {
padding: 0 30rpx 74rpx;
border-radius: 0 0 44rpx 44rpx;
background:
radial-gradient(120% 90% at 85% -10%, rgba(255, 255, 255, 0.28), transparent 55%),
linear-gradient(165deg, #a98bff, #7b5bf0 52%, #5b2e9e);
}
.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__ph {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.hero__sub {
margin-top: 18rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
}
/* 上浮内容区(对齐 tb-body 语言) */
.body {
position: relative;
z-index: 2;
margin: -50rpx 24rpx 0;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More