功能、UI更新
12
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
71
scripts/selfcheck-naming.mjs
Normal 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)
|
||||
189
src/App.vue
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 || '头像上传失败,请重试'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
757
src/components/card/CardTile.vue
Normal 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>
|
||||
274
src/components/coupon/CouponCard.vue
Normal 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>
|
||||
1004
src/components/event/SeatMap.vue
Normal 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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
25
src/components/home/PaneRefresher.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
136
src/components/member/PromoCodeInput.vue
Normal 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>
|
||||
948
src/components/mine/MineDrawers.vue
Normal 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>
|
||||
69
src/components/tool/ToolIdle.vue
Normal 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>
|
||||
123
src/components/tool/ToolPage.vue
Normal 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>
|
||||
94
src/components/tool/ToolRunning.vue
Normal 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>
|
||||
92
src/components/tool/ToolSliderRow.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
142
src/composables/useImageTool.ts
Normal 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
@@ -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
|
||||
}
|
||||
118
src/config/card-templates.ts
Normal 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
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -53,6 +53,9 @@
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"optimization": {
|
||||
"subPackages": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
279
src/pkg-member/coupon/index.vue
Normal 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>
|
||||
891
src/pkg-member/member/detail.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
825
src/pkg-style/sections/SecOne.vue
Normal 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>
|
||||
508
src/pkg-style/sections/SecThree.vue
Normal 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>
|
||||
664
src/pkg-style/sections/SecTwo.vue
Normal 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
@@ -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>
|
||||
BIN
src/pkg-style/static/icons/anchor.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/pkg-style/static/icons/atom.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/pkg-style/static/icons/avatar-woman.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/badge-bolt.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/badge-crown.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/pkg-style/static/icons/badge-heart.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/pkg-style/static/icons/badge-star.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/pkg-style/static/icons/balloon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/pkg-style/static/icons/bell.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/pkg-style/static/icons/bookA.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/pkg-style/static/icons/brain.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/pkg-style/static/icons/briefcase.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/pkg-style/static/icons/broballoon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/pkg-style/static/icons/burger.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/pkg-style/static/icons/cake.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/pkg-style/static/icons/camera.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/pkg-style/static/icons/cameraretro.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/pkg-style/static/icons/cart.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/pkg-style/static/icons/chart.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/chat.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/pkg-style/static/icons/chatdots.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/clipboard.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/pkg-style/static/icons/clock.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/pkg-style/static/icons/cloud-down.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/pkg-style/static/icons/cloud-video.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/pkg-style/static/icons/coffee.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/college.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/pkg-style/static/icons/community.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/pkg-style/static/icons/compass.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/pkg-style/static/icons/dinner.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/pkg-style/static/icons/docscheck.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/pkg-style/static/icons/donut.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/pkg-style/static/icons/exampaper.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/pkg-style/static/icons/film.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/pkg-style/static/icons/folder.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/pkg-style/static/icons/gamepad.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/pkg-style/static/icons/gift.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/pkg-style/static/icons/gradcap.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/pkg-style/static/icons/hearts.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/pkg-style/static/icons/hero-bot.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/pkg-style/static/icons/icepop.png
Normal file
|
After Width: | Height: | Size: 17 KiB |