This commit is contained in:
Timi
2026-08-13 17:36:59 +08:00
parent 37d7972cf9
commit 6ebd83edbc
11 changed files with 975 additions and 27 deletions
+269
View File
@@ -0,0 +1,269 @@
<template>
<t-popup v-model="popupVisible" placement="bottom">
<div class="tag-selector bg-white">
<div class="title-bar">
<t-button variant="text" size="small" @click="cancelSelection">取消</t-button>
<h3 class="title">选择标签</h3>
<t-button theme="primary" variant="text" size="small" @click="confirmSelection">确定</t-button>
</div>
<div class="search-row">
<t-input
class="input"
size="small"
v-model.trim="keyword"
borderless
clearable
placeholder="搜索标签"
/>
<template v-if="canCreateTag">
<t-input
class="input"
size="small"
v-model.trim="newTagName"
borderless
clearable
placeholder="新增标签名称"
/>
<t-button
theme="primary"
size="small"
:loading="isCreating"
:disabled="isCreating"
@click="createTag"
>新增</t-button>
</template>
</div>
<div class="list">
<t-tag
v-for="item in visibleTagList"
:key="item.id"
class="item"
:theme="isSelected(item) ? 'primary' : 'default'"
size="extra-large"
:variant="isSelected(item) ? 'dark' : 'light-outline'"
@click="toggleTag(item)"
>
<span v-text="getTagName(item)" />
</t-tag>
<div v-if="!visibleTagList.length" class="empty" v-text="emptyText" />
</div>
</div>
</t-popup>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { useUserStore } from "timi-web";
import TagAPI from "@/api/TagAPI";
import type { Tag } from "@/types/Tag";
import { corePermissionCodes } from "@/utils/permission";
interface Props {
modelValue: string[];
visible: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [value: string[]];
"update:visible": [value: boolean];
change: [value: Tag[]];
}>();
const userStore = useUserStore();
const pageSize = 100;
const keyword = ref("");
const tagList = ref<Tag[]>([]);
const newTagName = ref("");
const isLoading = ref(false);
const isCreating = ref(false);
const draftSelectedIdList = ref<string[]>([]);
const canCreateTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.create));
const visibleTagList = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) {
return tagList.value;
}
return tagList.value.filter(item => getTagName(item).toLowerCase().includes(value));
});
const emptyText = computed(() => {
if (isLoading.value) {
return "正在加载标签";
}
return keyword.value.trim() ? "未找到匹配标签" : "暂无标签";
});
const popupVisible = computed({
get() {
return props.visible;
},
set(value: boolean) {
emit("update:visible", value);
}
});
function getTagName(tag: Tag): string {
return tag.name || tag.zhCN || "未命名标签";
}
function isSelected(tag: Tag): boolean {
return !!tag.id && draftSelectedIdList.value.includes(tag.id);
}
function syncDraftSelection(): void {
draftSelectedIdList.value = [...props.modelValue];
}
function toggleTag(tag: Tag): void {
const id = tag.id;
if (!id) {
return;
}
if (draftSelectedIdList.value.includes(id)) {
draftSelectedIdList.value = draftSelectedIdList.value.filter(item => item !== id);
return;
}
draftSelectedIdList.value = [...draftSelectedIdList.value, id];
}
async function loadTags(): Promise<void> {
isLoading.value = true;
try {
const result = await TagAPI.list({
index: 0,
size: pageSize
});
tagList.value = result.list || [];
} catch {
Toast.error("标签加载失败");
} finally {
isLoading.value = false;
}
}
async function createTag(): Promise<void> {
const name = newTagName.value.trim();
if (!name) {
Toast.warning("请输入标签名称");
return;
}
if (tagList.value.some(item => getTagName(item) === name)) {
Toast.warning("标签已存在");
return;
}
isCreating.value = true;
try {
const tag = await TagAPI.create({
name,
zhCN: name
});
newTagName.value = "";
tagList.value = [tag, ...tagList.value];
if (tag.id) {
draftSelectedIdList.value = [...draftSelectedIdList.value, tag.id];
}
Toast.success("标签已新增");
} catch {
Toast.error("标签新增失败");
} finally {
isCreating.value = false;
}
}
function cancelSelection(): void {
syncDraftSelection();
emit("update:visible", false);
}
function confirmSelection(): void {
const selectedIdList = [...draftSelectedIdList.value];
emit("update:modelValue", selectedIdList);
emit("change", tagList.value.filter(item => item.id && selectedIdList.includes(item.id)));
emit("update:visible", false);
}
watch(() => props.visible, async (value) => {
if (!value) {
syncDraftSelection();
keyword.value = "";
newTagName.value = "";
return;
}
syncDraftSelection();
await loadTags();
});
</script>
<style scoped lang="less">
.tag-selector {
display: flex;
height: 78vh;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
flex-direction: column;
.title-bar {
padding: 0 1rem;
display: grid;
grid-template-columns: 4rem 1fr 4rem;
align-items: center;
}
.title {
margin: 0;
font-size: 1rem;
text-align: center;
}
.search-row {
gap: .5rem;
display: flex;
padding: .5rem 1rem;
align-items: center;
.input {
--td-input-vertical-padding: 6px;
border: 1px solid var(--td-component-border);
border-radius: .875rem;
}
}
.list {
gap: .75rem;
flex: 1;
overflow: auto;
min-height: 0;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
padding: 0 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
.item {
flex: 1 1 auto;
margin: 0;
min-width: 5rem;
max-width: 100%;
border-radius: .75rem;
justify-content: center;
}
.empty {
color: #52607a;
padding: 2rem 1rem;
text-align: center;
}
}
}
:global(html.theme-dark) {
.tag-selector {
background: rgba(15, 23, 42, .96);
.empty {
color: #94a3b8;
}
}
}
</style>