This commit is contained in:
owenchen committed 2026-10-14 14:48:29 +08:00
1 parent 8a2adb0a92
commit bd678b0112
22 files changed
+1968 -81

No files matched your search

@@ -0,0 +1,203 @@
<script setup lang="ts">
/**
* 目录面包屑(仅子目录显示,页面自行 v-if):样式对齐网盘 App 路径条——白底描边圆角、细 chevron 分隔。
* 显示方式:放得下就全量展示;放不下时保留「根 + … + 尾部若干段」,被折叠的中间段收进「…」。
* 折叠不是 CSS 裁切:H5 渲染后实测内部宽度,逐段把折叠点向后推,直到不再溢出(非 H5 端按段数近似折叠)。
* 点「…」跳到被折叠段里最深的一层;点其它段跳到该层;当前目录(最后一段)点击无操作。
* 纯展示组件:只 emit('nav', 段下标),截断与重查由页面/composable 决定。
*/
import type { PropType } from "vue";
import type { PathNode } from "@/data/custom/fileBrowser";
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
const props = defineProps({
/** 从根到当前目录的完整路径栈 */
items: {
type: Array as PropType<PathNode[]>,
default: () => [],
},
});
const emit = defineEmits<{
(e: "nav", index: number): void;
}>();
const instance = getCurrentInstance();
/** 折叠点:下标 1..collapseFrom-1 的段被收进「…」;-1 表示全量展示 */
const collapseFrom = ref(-1);
/** 折到底(根+…+末段)仍放不下:允许末段压缩出省略号。
* 平时必须为 false——末段一旦可压缩,scrollWidth 就测不出真实溢出,折叠永远不会触发 */
const tailClamped = ref(false);
interface Segment {
node: PathNode;
index: number;
ellipsis: boolean;
}
/** 实际渲染的段序列:根 +(可选 …)+ 尾部 */
const shownSegments = computed<Segment[]>(() => {
const items = props.items;
if (collapseFrom.value < 1) {
return items.map((node, index) => ({ node, index, ellipsis: false }));
}
const segments: Segment[] = [{ node: items[0], index: 0, ellipsis: false }];
segments.push({
node: { value: "", displayName: "…" },
index: collapseFrom.value - 1,
ellipsis: true,
});
items.slice(collapseFrom.value).forEach((node, i) => {
segments.push({ node, index: collapseFrom.value + i, ellipsis: false });
});
return segments;
});
function onSegmentTap(seg: Segment): void {
// 当前目录段不用跳;「…」跳到被折叠段里最深的一层(其下标即 seg.index)
if (seg.index >= props.items.length - 1) {
return;
}
emit("nav", seg.index);
}
/** 是否仍在溢出:H5 实测滚动宽;其它端拿段数近似(超过 4 段继续折) */
function isOverflowing(): boolean {
// #ifdef H5
const root = instance?.proxy?.$el as HTMLElement | undefined;
const inner = root?.querySelector?.(".file-breadcrumb__rest-inner");
if (!inner) {
return false;
}
return inner.scrollWidth > inner.clientWidth + 1;
// #endif
// #ifndef H5
return props.items.length - Math.max(collapseFrom.value, 1) > 3;
// #endif
}
/** 重测折叠:先全展开,放不下就把折叠点一段一段往后推;折到底仍溢出才夹末段 */
async function remeasure(): Promise<void> {
collapseFrom.value = -1;
tailClamped.value = false;
if (props.items.length <= 1) {
return;
}
await nextTick();
let guard = props.items.length + 2;
while (isOverflowing() && guard-- > 0) {
// 折叠下限:根 + … + 末段(collapseFrom = length-1);两段路径无中间段可折
if (props.items.length > 2 && collapseFrom.value < props.items.length - 1) {
collapseFrom.value = collapseFrom.value < 2 ? 2 : collapseFrom.value + 1;
await nextTick();
continue;
}
tailClamped.value = true;
break;
}
}
// 路径内容(非引用)变化都要重测:下钻/返回/面包屑跳转/重命名都会改变段数或段宽
watch(() => props.items.map(node => `${node.value}|${node.displayName}`).join(">"), remeasure);
// 窗口尺寸变化(横竖屏切换、H5 拉窗口)也要重测:折叠是按实测宽度算的
onMounted(() => {
remeasure();
uni.onWindowResize(remeasure);
});
onUnmounted(() => {
uni.offWindowResize(remeasure);
});
</script>
<template>
<view v-if="items.length > 1" class="file-breadcrumb">
<text class="file-breadcrumb__root" @tap="onSegmentTap({ node: items[0], index: 0, ellipsis: false })">
{{ items[0].displayName }}
</text>
<view class="file-breadcrumb__rest">
<view class="file-breadcrumb__rest-inner">
<template v-for="seg in shownSegments.slice(1)" :key="`${seg.index}-${seg.node.value}`">
<u-icon class="file-breadcrumb__sep" name="arrow-right" size="22" color="#8f959e" />
<text class="file-breadcrumb__item"
:class="{ 'file-breadcrumb__item--ellipsis': seg.ellipsis, 'file-breadcrumb__item--clamped': tailClamped && seg.index === items.length - 1 }"
@tap="onSegmentTap(seg)">
{{ seg.node.displayName }}
</text>
</template>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 面包屑:描边圈 + 浅浅的半透明白底(透出渐变底不发死白),分隔为细 chevron */
.file-breadcrumb {
display: flex;
align-items: center;
gap: 10rpx;
padding: 20rpx 28rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 20rpx;
background-color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.file-breadcrumb__root {
flex-shrink: 0;
font-size: 28rpx;
color: #2b8fff;
}
.file-breadcrumb__rest {
flex: 1;
min-width: 0;
overflow: hidden;
}
/* 左起排列(对齐截图:短路径贴左,放不下才折叠出 …);测量溢出靠 scrollWidth 对比 */
.file-breadcrumb__rest-inner {
display: flex;
align-items: center;
white-space: nowrap;
}
.file-breadcrumb__item {
flex-shrink: 0;
font-size: 28rpx;
color: #2b8fff;
white-space: nowrap;
/* uni-text 内部 span 默认 pre-line,会让 nowrap/ellipsis 失效,须穿透覆盖 */
:deep(span) {
white-space: nowrap;
}
&--ellipsis {
padding: 0 4rpx;
color: #2b8fff;
}
/* 折到底仍溢出时的末段兜底:flex-shrink+min-width:0 让末段收缩进可视框,
* 内部 span 转 block 吃满收缩后的宽度出 CSS 省略号 */
&--clamped {
flex-shrink: 1;
min-width: 0;
max-width: 100%;
:deep(span) {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.file-breadcrumb__sep {
flex-shrink: 0;
margin: 0 6rpx;
}
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
/**
* 文件网格列表:卡片展示文件夹图标 / 文件类型图标、名称、元数据(文件大小 + 修改时间,
* 文件夹只有时间、不显示用户),右上角三点「更多」。viewMode 支持 grid(三列卡片)与 list(单行列表)。
* 多选模式(selectMode)下点击卡片改为勾选/取消;数据、选中集均由页面持有,这里只渲染与上报。
*/
import type { PropType } from "vue";
import type { FileItem, FileKind } from "@/data/custom/fileBrowser";
const props = defineProps({
/** 已过滤排序好的条目,组件按原顺序渲染 */
files: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
/** 排布模式:grid 九宫格 / list 列表 */
viewMode: {
type: String as PropType<"grid" | "list">,
default: "grid",
},
/** 是否处于多选模式 */
selectMode: {
type: Boolean,
default: false,
},
/** 多选模式下已勾选的 id 集合 */
selectedIds: {
type: Array as PropType<number[]>,
default: () => [],
},
/** 空列表提示文案(页面翻译好传入) */
emptyText: {
type: String,
default: "",
},
});
const emit = defineEmits<{
/** 点开条目(非多选模式的单击) */
(e: "open", file: FileItem): void;
/** 卡片右上角「更多」 */
(e: "more", file: FileItem): void;
/** 多选模式下勾选状态变化 */
(e: "toggle-select", id: number): void;
}>();
// 文件类型 -> 图标与配色(文件夹走纯 CSS 造型,不用字体图标)
const kindVisual: Record<Exclude<FileKind, "folder">, { icon: string; color: string }> = {
image: { icon: "photo-fill", color: "#0ea5e9" },
video: { icon: "play-right-fill", color: "#8b5cf6" },
document: { icon: "file-text-fill", color: "#3b82f6" },
audio: { icon: "volume-up", color: "#f59e0b" },
};
function visualOf(kind: FileKind): { icon: string; color: string } | null {
return kind === "folder" ? null : kindVisual[kind];
}
function isSelected(file: FileItem): boolean {
return props.selectedIds.includes(file.id);
}
function onCardTap(file: FileItem): void {
if (props.selectMode) {
emit("toggle-select", file.id);
return;
}
emit("open", file);
}
</script>
<template>
<view class="file-grid-list" :class="{ 'file-grid-list--list': viewMode === 'list' }">
<view v-if="!files.length" class="file-grid-list__empty">
<text class="file-grid-list__empty-text">
{{ emptyText }}
</text>
</view>
<view v-for="file in files" :key="file.id" class="fgl-card"
:class="{ 'fgl-card--selected': selectMode && isSelected(file) }">
<view class="fgl-card__more" @tap.stop="emit('more', file)">
<u-icon name="more-dot-fill" size="30" color="#b8bcc4" />
</view>
<view class="fgl-card__body" @tap="onCardTap(file)">
<!-- 文件夹:纯 CSS 造型(图标库没有 folder),主体 + 左上页签 -->
<view v-if="file.kind === 'folder'" class="fgl-card__folder">
<view class="fgl-card__folder-tab" />
</view>
<u-icon v-else :name="visualOf(file.kind)!.icon" size="88" :color="visualOf(file.kind)!.color" />
<view class="fgl-card__info">
<text class="fgl-card__name">
{{ file.name }}
</text>
<view class="fgl-card__meta">
<text v-if="file.sizeText" class="fgl-card__meta-text">
{{ file.sizeText }}
</text>
<text class="fgl-card__meta-text">
{{ file.modifiedAt }}
</text>
</view>
</view>
<view v-if="selectMode" class="fgl-card__check">
<u-icon :name="isSelected(file) ? 'checkmark-circle-fill' : 'checkmark-circle'" size="40"
:color="isSelected(file) ? '#2b8fff' : '#c0c4cc'" />
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.file-grid-list {
display: flex;
flex-wrap: wrap;
padding: 16rpx 20rpx 40rpx;
}
.file-grid-list__empty {
width: 100%;
padding: 120rpx 0;
text-align: center;
}
.file-grid-list__empty-text {
font-size: 26rpx;
color: #9aa0a6;
}
.fgl-card {
position: relative;
width: calc(100% / 3 - 16rpx);
margin: 8rpx;
padding-bottom: 20rpx;
border-radius: 20rpx;
background-color: #f7f8fa;
&--selected {
background-color: #eaf4ff;
}
}
.fgl-card__more {
position: absolute;
top: 12rpx;
right: 12rpx;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 52rpx;
height: 44rpx;
border-radius: 12rpx;
background-color: rgba(31, 35, 41, 0.05);
}
.fgl-card__body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 12rpx 0;
}
/* ---- 纯 CSS 文件夹:蓝色渐变圆角主体 + 左上页签 ---- */
.fgl-card__folder {
position: relative;
width: 132rpx;
height: 104rpx;
margin: 14rpx 0;
border-radius: 16rpx;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 10rpx 20rpx rgba(43, 143, 255, 0.25);
}
.fgl-card__folder-tab {
position: absolute;
top: -16rpx;
left: 14rpx;
width: 56rpx;
height: 24rpx;
border-radius: 10rpx 10rpx 0 0;
background: linear-gradient(135deg, #6ec2ff 0%, #4aa6ff 100%);
}
.fgl-card__info {
width: 100%;
padding: 0 12rpx;
}
.fgl-card__name {
display: block;
overflow: hidden;
font-size: 28rpx;
font-weight: 500;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
.fgl-card__meta {
display: flex;
gap: 8rpx;
margin-top: 6rpx;
overflow: hidden;
}
.fgl-card__meta-text {
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
font-size: 22rpx;
color: #9aa0a6;
text-overflow: ellipsis;
white-space: nowrap;
}
.fgl-card__check {
position: absolute;
right: 12rpx;
bottom: 16rpx;
}
/* ---- 列表模式:单行排布,覆盖网格默认样式 ---- */
.file-grid-list--list {
flex-direction: column;
flex-wrap: nowrap;
padding: 8rpx 24rpx 40rpx;
}
.file-grid-list--list .fgl-card {
width: 100%;
margin: 0 0 16rpx;
padding-bottom: 0;
}
.file-grid-list--list .fgl-card__more {
top: 50%;
transform: translateY(-50%);
background-color: transparent;
}
.file-grid-list--list .fgl-card__body {
flex-direction: row;
padding: 24rpx 64rpx 24rpx 24rpx;
}
.file-grid-list--list .fgl-card__folder {
width: 72rpx;
height: 56rpx;
margin: 0 24rpx 0 0;
border-radius: 10rpx;
}
.file-grid-list--list .fgl-card__folder-tab {
top: -10rpx;
left: 8rpx;
width: 30rpx;
height: 14rpx;
border-radius: 6rpx 6rpx 0 0;
}
.file-grid-list--list .fgl-card__info {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* 分类标签筛选:横向标签(全部/图片/视频/文档/音频)+ 右侧「列表视图切换」「多选」按钮。
* 标签内容由页面传入(翻译在页面做);当前选中、视图模式、多选态都是外部状态,
* 组件只上报用户意图(change / toggle-view / toggle-select),自身不改任何东西。
*/
import type { PropType } from "vue";
import type { FileTabItem } from "@/data/custom/fileBrowser";
defineProps({
/** 分类标签列表 */
tabs: {
type: Array as PropType<FileTabItem[]>,
default: () => [],
},
/** 当前选中的分类 key */
active: {
type: String,
default: "all",
},
/** 当前视图:grid 九宫格 / list 列表 */
viewMode: {
type: String as PropType<"grid" | "list">,
default: "list",
},
/** 是否处于多选模式 */
selectMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(e: "change", key: FileTabItem["key"]): void;
(e: "sort"): void;
(e: "toggle-view"): void;
(e: "toggle-select"): void;
}>();
</script>
<template>
<view class="file-tab-filter">
<scroll-view class="file-tab-filter__scroll" scroll-x :show-scrollbar="false">
<view class="file-tab-filter__tabs">
<text v-for="tab in tabs" :key="tab.key" class="file-tab-filter__tab"
:class="{ 'file-tab-filter__tab--active': tab.key === active }" @tap="emit('change', tab.key)">
{{ tab.label }}
</text>
</view>
</scroll-view>
<view class="file-tab-filter__tools">
<!-- 排序(从页面导航栏迁来,倒数第3位):level 阶梯横线,比原 order 更有排序感 -->
<view class="file-tab-filter__tool" @tap="emit('sort')">
<u-icon name="level" size="36" color="#1f2329" />
</view>
<!-- 显示的是「点后会切到什么」:网格态给列表图标,列表态给网格图标 -->
<view class="file-tab-filter__tool" @tap="emit('toggle-view')">
<u-icon :name="viewMode === 'grid' ? 'list' : 'grid-fill'" size="36" color="#1f2329" />
</view>
<view class="file-tab-filter__tool" @tap="emit('toggle-select')">
<u-icon name="checkmark-circle" size="36" :color="selectMode ? '#2b8fff' : '#1f2329'" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 分类条卡片:与面包屑/搜索框同风格——描边圈 + 浅半透明白底,透出渐变底 */
.file-tab-filter {
display: flex;
align-items: center;
margin: 20rpx 24rpx 0;
padding: 8rpx 16rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 24rpx;
background-color: rgba(255, 255, 255, 0.45);
}
.file-tab-filter__scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.file-tab-filter__tabs {
display: inline-flex;
align-items: center;
gap: 16rpx;
padding-left: 20rpx;
}
.file-tab-filter__tab {
padding: 8rpx 22rpx;
border-radius: 999rpx;
background-color: #f2f3f5;
font-size: 24rpx;
color: #5c626b;
flex-shrink: 0;
&--active {
background-color: #1f2329;
color: #ffffff;
font-weight: 600;
}
}
.file-tab-filter__tools {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.file-tab-filter__tool {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* 悬浮操作按钮:右下角圆形蓝色渐变「+」,固定在底部导航之上。
* 只做外观与点击上报;新建/上传等动作语义由页面处理。
*/
defineProps({
/** 额外底部偏移(rpx 数值),供不同页面避开各自的底部元素 */
bottomOffset: {
type: Number,
default: 0,
},
});
const emit = defineEmits<{
(e: "click"): void;
}>();
</script>
<template>
<view class="float-add-btn" :style="{ bottom: `${180 + bottomOffset}rpx` }" @tap="emit('click')">
<u-icon name="plus" size="52" color="#ffffff" />
</view>
</template>
<style lang="scss" scoped>
.float-add-btn {
position: fixed;
right: 40rpx;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 12rpx 32rpx rgba(43, 143, 255, 0.4);
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
/**
* 文件页顶部导航栏:「返回箭头 + 居中标题 + 右侧两个显隐开关」。
* 根目录不显示返回(showBack=false);标题两侧等宽占位保证真居中。
* 右侧 search/grid 两个图标分别切换页面里搜索框与快捷入口区的显隐,
* 点亮(蓝)= 对应区域显示,置灰 = 隐藏;排序按钮在 file-tab-filter 工具区。
* 纯展示组件:不自己做导航与显隐逻辑,按钮语义一律 emit 给页面决定。
* 顶栏配色跟随截图(白底黑字),不并入 app-page 的绿色 u-navbar,故页面用 hide-nav 让位。
*/
const props = defineProps({
/** 页面标题(页面用 t() 翻译好后传进来) */
title: {
type: String,
required: true,
},
/** 是否显示返回箭头:仅进入子目录后显示 */
showBack: {
type: Boolean,
default: false,
},
/** 搜索框当前是否可见(决定开关图标高亮态) */
searchOn: {
type: Boolean,
default: true,
},
/** 快捷入口区当前是否可见(决定开关图标高亮态) */
quickOn: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{
(e: "back"): void;
(e: "toggle-search"): void;
(e: "toggle-quick"): void;
}>();
</script>
<template>
<view class="page-navbar">
<view class="page-navbar__side page-navbar__side--left">
<view v-if="props.showBack" class="page-navbar__back" @tap="emit('back')">
<u-icon name="arrow-left" size="42" color="#1f2329" />
</view>
</view>
<text class="page-navbar__title">
{{ props.title }}
</text>
<view class="page-navbar__side page-navbar__side--right">
<!-- 切换搜索框显隐 -->
<view class="page-navbar__btn" @tap="emit('toggle-search')">
<u-icon name="search" size="38" :color="props.searchOn ? '#2b8fff' : '#8f959e'" />
</view>
<!-- 切换快捷入口区显隐 -->
<view class="page-navbar__btn" @tap="emit('toggle-quick')">
<u-icon name="grid" size="38" :color="props.quickOn ? '#2b8fff' : '#8f959e'" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.page-navbar {
position: sticky;
top: 0;
z-index: 9;
display: flex;
align-items: center;
padding: 16rpx 24rpx;
/* 透明顶栏:透出页面的浅蓝→白渐变底 */
background-color: transparent;
}
/* 左右等宽占位(容纳右侧两个按钮):任何状态下标题都严格居中 */
.page-navbar__side {
display: flex;
align-items: center;
gap: 6rpx;
width: 148rpx;
min-height: 64rpx;
flex-shrink: 0;
}
.page-navbar__side--left {
justify-content: flex-start;
}
.page-navbar__side--right {
justify-content: flex-end;
}
.page-navbar__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
/* 显隐开关按钮:与 file-tab-filter 工具图标同尺寸,保持一行工具观感 */
.page-navbar__btn {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
}
.page-navbar__title {
flex: 1;
text-align: center;
font-size: 38rpx;
font-weight: 600;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 快捷功能入口:横向滚动的图标按钮组(最近访问 / 收藏 / 已下载 / 团队文件 / 共享文件)。
* 单项内容(图标名 + 渐变色 + 文案)全部由页面通过 items 传入,组件只渲染与派发点击。
*/
import type { PropType } from "vue";
import type { QuickEntryItem } from "@/data/custom/fileBrowser";
defineProps({
/** 入口列表,顺序即展示顺序 */
items: {
type: Array as PropType<QuickEntryItem[]>,
default: () => [],
},
});
const emit = defineEmits<{
(e: "click", key: string): void;
}>();
</script>
<template>
<scroll-view class="quick-entry" scroll-x :show-scrollbar="false">
<view class="quick-entry__row">
<view v-for="item in items" :key="item.key" class="quick-entry__item" @tap="emit('click', item.key)">
<view class="quick-entry__tile"
:style="{ backgroundImage: `linear-gradient(135deg, ${item.bgFrom}, ${item.bgTo})` }">
<u-icon :name="item.icon" size="44" color="#ffffff" />
</view>
<text class="quick-entry__label">
{{ item.label }}
</text>
</view>
</view>
</scroll-view>
</template>
<style lang="scss" scoped>
/* 透明快捷入口:透出页面渐变底;左右留边仍由页面外层包装负责
(scroll-view 根节点固定 100% 宽,自身加 margin 会右侧溢出被裁,实测踩过) */
.quick-entry {
padding: 28rpx 0 24rpx;
border-radius: 24rpx;
background-color: transparent;
white-space: nowrap;
}
.quick-entry__row {
display: inline-flex;
align-items: flex-start;
padding: 0 32rpx;
}
.quick-entry__item {
display: flex;
flex-direction: column;
align-items: center;
width: 128rpx;
flex-shrink: 0;
}
.quick-entry__tile {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
box-shadow: 0 8rpx 16rpx rgba(31, 35, 41, 0.08);
}
.quick-entry__label {
margin-top: 14rpx;
font-size: 26rpx;
color: #1f2329;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* 搜索框:uView Pro 的 u-input + 放大镜前缀图标,圆角浅灰底(对齐截图样式)。
* 输入即回传(配合 v-model 语义),键盘确认派发 search 事件;
* 注意 uview-pro 的 u-input 只派发 input/update:modelValue,没有 change 事件,
* 绑错 @change 会导致关键字永不回传(实测踩过)。组件不碰接口,过滤时机由页面决定。
*/
import { computed } from "vue";
const props = defineProps({
/** 关键字(v-model 语义) */
modelValue: {
type: String,
default: "",
},
/** 占位文案(页面翻译好传入) */
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "search", value: string): void;
}>();
// u-input 默认白底带边框,这里压成透明让它融进外层浅灰圆角容器
const inputStyle = computed(() => ({
backgroundColor: "transparent",
}));
function onInput(value: string): void {
emit("update:modelValue", value);
}
function onConfirm(value: string): void {
emit("search", value ?? props.modelValue);
}
function onClear(): void {
emit("update:modelValue", "");
}
</script>
<template>
<view class="search-input">
<view class="search-input__box">
<u-icon name="search" size="34" color="#9aa0a6" />
<view class="search-input__field">
<u-input :model-value="modelValue" :placeholder="placeholder" :border="false" clearable
:custom-style="inputStyle" placeholder-style="color: #9aa0a6" confirm-type="search" @input="onInput"
@confirm="onConfirm" @clear="onClear" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 分区卡片风格:外层透明露渐变底,胶囊内部浅白色、留描边定形 */
.search-input {
padding: 2rpx 24rpx 0;
}
.search-input__box {
display: flex;
align-items: center;
gap: 16rpx;
padding: 10rpx 28rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 399rpx;
background-color: rgba(255, 255, 255, 0.55);
}
.search-input__field {
flex: 1;
}
</style>