diff --git a/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.module.scss b/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.module.scss
index 77a20989..a48808f3 100644
--- a/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.module.scss
+++ b/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.module.scss
@@ -343,3 +343,71 @@
justify-content: flex-end;
gap: 12px;
}
+
+// 用户ID输入
+.userIdsInput {
+ font-family: monospace;
+ font-size: 13px;
+ line-height: 1.6;
+ min-height: 120px;
+}
+
+.formHint {
+ font-size: 12px;
+ color: #9ca3af;
+}
+
+// 用户列表
+.usersList {
+ min-height: 200px;
+}
+
+.usersHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ font-size: 13px;
+ color: #6b7280;
+}
+
+.usersTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+
+ th,
+ td {
+ padding: 10px 12px;
+ text-align: left;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ th {
+ font-weight: 500;
+ color: #6b7280;
+ background: #f9fafb;
+ }
+
+ td {
+ color: #374151;
+ }
+
+ tr:hover td {
+ background: #f9fafb;
+ }
+}
+
+.removeBtn {
+ padding: 4px 8px;
+ font-size: 12px;
+ color: #ef4444;
+ background: transparent;
+ border: 1px solid #fecaca;
+ border-radius: 4px;
+ cursor: pointer;
+
+ &:hover {
+ background: #fef2f2;
+ }
+}
diff --git a/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.tsx b/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.tsx
index 7a97751e..94414462 100644
--- a/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.tsx
+++ b/frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.tsx
@@ -9,10 +9,12 @@ import {
UserTag,
TagType,
TagValueType,
+ TagUserItem,
TAG_TYPE_OPTIONS,
TAG_VALUE_TYPE_OPTIONS,
TAG_COLORS,
} from '@/services/userTagService';
+import { formatDateTime } from '@/utils/formatters';
import styles from './UserTagsTab.module.scss';
/**
@@ -51,6 +53,22 @@ export const UserTagsTab = () => {
// 删除确认
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'category' | 'tag'; id: string } | null>(null);
+ // 分配用户弹窗状态
+ const [showAssignModal, setShowAssignModal] = useState(false);
+ const [assigningTag, setAssigningTag] = useState(null);
+ const [assignForm, setAssignForm] = useState({
+ userIds: '',
+ value: '',
+ reason: '',
+ });
+ const [assigning, setAssigning] = useState(false);
+
+ // 查看用户弹窗状态
+ const [showUsersModal, setShowUsersModal] = useState(false);
+ const [viewingTag, setViewingTag] = useState(null);
+ const [tagUsers, setTagUsers] = useState([]);
+ const [loadingUsers, setLoadingUsers] = useState(false);
+
// 加载数据
const loadData = useCallback(async () => {
try {
@@ -233,6 +251,90 @@ export const UserTagsTab = () => {
}
};
+ // =====================
+ // 分配用户操作
+ // =====================
+
+ const handleOpenAssignModal = (tag: UserTag) => {
+ setAssigningTag(tag);
+ setAssignForm({ userIds: '', value: '', reason: '' });
+ setShowAssignModal(true);
+ };
+
+ const handleAssignUsers = async () => {
+ if (!assigningTag) return;
+
+ const userIds = assignForm.userIds
+ .split(/[\n,]/)
+ .map(id => id.trim())
+ .filter(Boolean);
+
+ if (userIds.length === 0) {
+ toast.error('请输入用户ID');
+ return;
+ }
+
+ try {
+ setAssigning(true);
+ const result = await userTagService.batchAssignTag({
+ tagId: assigningTag.id,
+ accountSequences: userIds,
+ value: assignForm.value.trim() || undefined,
+ reason: assignForm.reason.trim() || undefined,
+ });
+
+ if (result.failed > 0) {
+ toast.warning(`成功: ${result.success}, 失败: ${result.failed}`);
+ } else {
+ toast.success(`成功分配给 ${result.success} 个用户`);
+ }
+
+ setShowAssignModal(false);
+ loadData(); // 刷新以更新预估用户数
+ } catch (err) {
+ toast.error((err as Error).message || '分配失败');
+ } finally {
+ setAssigning(false);
+ }
+ };
+
+ // =====================
+ // 查看用户操作
+ // =====================
+
+ const handleViewUsers = async (tag: UserTag) => {
+ setViewingTag(tag);
+ setShowUsersModal(true);
+ setLoadingUsers(true);
+
+ try {
+ const response = await userTagService.getTagUsers(tag.id, { limit: 100 });
+ setTagUsers(response.items);
+ } catch (err) {
+ toast.error((err as Error).message || '加载用户失败');
+ } finally {
+ setLoadingUsers(false);
+ }
+ };
+
+ const handleRemoveUserTag = async (accountSequence: string) => {
+ if (!viewingTag) return;
+
+ try {
+ await userTagService.removeTag({
+ accountSequence,
+ tagId: viewingTag.id,
+ });
+ toast.success('已移除用户标签');
+ // 刷新用户列表
+ const response = await userTagService.getTagUsers(viewingTag.id, { limit: 100 });
+ setTagUsers(response.items);
+ loadData(); // 刷新以更新预估用户数
+ } catch (err) {
+ toast.error((err as Error).message || '移除失败');
+ }
+ };
+
// 获取标签类型显示
const getTagTypeLabel = (type: TagType) => {
const opt = TAG_TYPE_OPTIONS.find(o => o.value === type);
@@ -314,6 +416,8 @@ export const UserTagsTab = () => {
{tag.isAdvertisable && 可投放}
+
+
@@ -497,6 +601,127 @@ export const UserTagsTab = () => {
: '确定要删除这个标签吗?相关的用户标签关联也会被删除。'}
+
+ {/* 分配用户弹窗 */}
+ setShowAssignModal(false)}
+ footer={
+
+
+
+
+ }
+ width={560}
+ >
+
+
+
+
+ {assigningTag?.valueType !== 'BOOLEAN' && (
+
+
+ {assigningTag?.valueType === 'ENUM' && assigningTag.enumValues ? (
+
+ ) : (
+ setAssignForm({ ...assignForm, value: e.target.value })}
+ placeholder={assigningTag?.valueType === 'NUMBER' ? '输入数字' : '输入值'}
+ />
+ )}
+
+ )}
+
+
+ setAssignForm({ ...assignForm, reason: e.target.value })}
+ placeholder="如: 批量导入, 运营活动等"
+ />
+
+
+
+
+ {/* 查看用户弹窗 */}
+ setShowUsersModal(false)}
+ footer={
+
+
+
+
+ }
+ width={640}
+ >
+
+ {loadingUsers ? (
+
加载中...
+ ) : tagUsers.length === 0 ? (
+
暂无用户拥有此标签
+ ) : (
+ <>
+
+ 共 {tagUsers.length} 个用户
+
+
+
+
+ | 用户ID |
+ 标签值 |
+ 分配时间 |
+ 操作 |
+
+
+
+ {tagUsers.map(user => (
+
+ | {user.accountSequence} |
+ {user.value || '-'} |
+ {formatDateTime(user.assignedAt)} |
+
+
+ |
+
+ ))}
+
+
+ >
+ )}
+
+
);
};
diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts
index 96291bb7..2f572983 100644
--- a/frontend/admin-web/src/infrastructure/api/endpoints.ts
+++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts
@@ -120,8 +120,10 @@ export const API_ENDPOINTS = {
ESTIMATE_USERS: (id: string) => `/v1/admin/tags/${id}/estimate-users`,
// 用户标签分配
ASSIGN: '/v1/admin/tags/assign',
+ BATCH_ASSIGN: '/v1/admin/tags/batch-assign',
REMOVE: '/v1/admin/tags/remove',
- USER_TAGS: (accountSequence: string) => `/v1/admin/tags/users/${accountSequence}`,
+ USER_TAGS: (accountSequence: string) => `/v1/admin/tags/user/${accountSequence}`,
+ TAG_USERS: (tagId: string) => `/v1/admin/tags/${tagId}/users`,
},
// 用户画像 - 分类规则 (admin-service)
diff --git a/frontend/admin-web/src/services/userTagService.ts b/frontend/admin-web/src/services/userTagService.ts
index ed37bf56..c8340712 100644
--- a/frontend/admin-web/src/services/userTagService.ts
+++ b/frontend/admin-web/src/services/userTagService.ts
@@ -137,6 +137,29 @@ export interface RemoveTagRequest {
tagId: string;
}
+/** 批量分配标签请求 */
+export interface BatchAssignTagRequest {
+ accountSequences: string[];
+ tagId: string;
+ value?: string;
+ reason?: string;
+}
+
+/** 批量操作结果 */
+export interface BatchOperationResult {
+ success: number;
+ failed: number;
+ errors: Array<{ accountSequence: string; error: string }>;
+}
+
+/** 标签下的用户 */
+export interface TagUserItem {
+ accountSequence: string;
+ value: string | null;
+ assignedAt: string;
+ assignedBy: string | null;
+}
+
// =====================
// 选项常量
// =====================
@@ -247,10 +270,20 @@ export const userTagService = {
return apiClient.post(API_ENDPOINTS.USER_TAGS.REMOVE, data);
},
+ /** 批量分配标签 */
+ async batchAssignTag(data: BatchAssignTagRequest): Promise {
+ return apiClient.post(API_ENDPOINTS.USER_TAGS.BATCH_ASSIGN, data);
+ },
+
/** 获取用户的标签 */
async getUserTags(accountSequence: string): Promise {
return apiClient.get(API_ENDPOINTS.USER_TAGS.USER_TAGS(accountSequence));
},
+
+ /** 获取标签下的用户列表 */
+ async getTagUsers(tagId: string, params: { limit?: number; offset?: number } = {}): Promise> {
+ return apiClient.get(API_ENDPOINTS.USER_TAGS.TAG_USERS(tagId), { params });
+ },
};
export default userTagService;