feat(admin-web): 添加用户标签分配和查看用户功能

- 在标签卡片添加"分配用户"和"查看用户"按钮
- 实现批量分配用户到标签的弹窗
- 实现查看标签下用户列表和移除用户功能
- 添加批量分配API (batch-assign)
- 添加获取标签用户API (tag/:id/users)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-24 17:36:07 -08:00
parent 18f24d5f4b
commit 41a47b1b53
4 changed files with 329 additions and 1 deletions

View File

@ -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;
}
}

View File

@ -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<UserTag | null>(null);
const [assignForm, setAssignForm] = useState({
userIds: '',
value: '',
reason: '',
});
const [assigning, setAssigning] = useState(false);
// 查看用户弹窗状态
const [showUsersModal, setShowUsersModal] = useState(false);
const [viewingTag, setViewingTag] = useState<UserTag | null>(null);
const [tagUsers, setTagUsers] = useState<TagUserItem[]>([]);
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 && <span className={styles.adBadge}></span>}
</div>
<div className={styles.tagActions}>
<button onClick={() => handleOpenAssignModal(tag)}></button>
<button onClick={() => handleViewUsers(tag)}></button>
<button onClick={() => handleEditTag(tag)}></button>
<button onClick={() => setDeleteConfirm({ type: 'tag', id: tag.id })}></button>
</div>
@ -497,6 +601,127 @@ export const UserTagsTab = () => {
: '确定要删除这个标签吗?相关的用户标签关联也会被删除。'}
</p>
</Modal>
{/* 分配用户弹窗 */}
<Modal
visible={showAssignModal}
title={`分配用户到标签: ${assigningTag?.name || ''}`}
onClose={() => setShowAssignModal(false)}
footer={
<div className={styles.modalFooter}>
<Button variant="outline" onClick={() => setShowAssignModal(false)}></Button>
<Button variant="primary" onClick={handleAssignUsers} disabled={assigning}>
{assigning ? '分配中...' : '确认分配'}
</Button>
</div>
}
width={560}
>
<div className={styles.form}>
<div className={styles.formGroup}>
<label>ID列表 *</label>
<textarea
className={styles.userIdsInput}
value={assignForm.userIds}
onChange={e => setAssignForm({ ...assignForm, userIds: e.target.value })}
placeholder="请输入用户ID账户序列号每行一个或用逗号分隔&#10;例如:&#10;000000000001&#10;000000000002"
rows={6}
/>
<span className={styles.formHint}>
{assignForm.userIds.split(/[\n,]/).filter(s => s.trim()).length}
</span>
</div>
{assigningTag?.valueType !== 'BOOLEAN' && (
<div className={styles.formGroup}>
<label> {assigningTag?.valueType === 'ENUM' ? `(可选值: ${assigningTag.enumValues?.join(', ')})` : ''}</label>
{assigningTag?.valueType === 'ENUM' && assigningTag.enumValues ? (
<select
value={assignForm.value}
onChange={e => setAssignForm({ ...assignForm, value: e.target.value })}
>
<option value=""></option>
{assigningTag.enumValues.map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
) : (
<input
type="text"
value={assignForm.value}
onChange={e => setAssignForm({ ...assignForm, value: e.target.value })}
placeholder={assigningTag?.valueType === 'NUMBER' ? '输入数字' : '输入值'}
/>
)}
</div>
)}
<div className={styles.formGroup}>
<label> ()</label>
<input
type="text"
value={assignForm.reason}
onChange={e => setAssignForm({ ...assignForm, reason: e.target.value })}
placeholder="如: 批量导入, 运营活动等"
/>
</div>
</div>
</Modal>
{/* 查看用户弹窗 */}
<Modal
visible={showUsersModal}
title={`标签用户: ${viewingTag?.name || ''}`}
onClose={() => setShowUsersModal(false)}
footer={
<div className={styles.modalFooter}>
<Button variant="outline" onClick={() => setShowUsersModal(false)}></Button>
<Button variant="primary" onClick={() => viewingTag && handleOpenAssignModal(viewingTag)}>
+
</Button>
</div>
}
width={640}
>
<div className={styles.usersList}>
{loadingUsers ? (
<div className={styles.loading}>...</div>
) : tagUsers.length === 0 ? (
<div className={styles.empty}></div>
) : (
<>
<div className={styles.usersHeader}>
<span> {tagUsers.length} </span>
</div>
<table className={styles.usersTable}>
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{tagUsers.map(user => (
<tr key={user.accountSequence}>
<td>{user.accountSequence}</td>
<td>{user.value || '-'}</td>
<td>{formatDateTime(user.assignedAt)}</td>
<td>
<button
className={styles.removeBtn}
onClick={() => handleRemoveUserTag(user.accountSequence)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</Modal>
</div>
);
};

View File

@ -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)

View File

@ -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<BatchOperationResult> {
return apiClient.post(API_ENDPOINTS.USER_TAGS.BATCH_ASSIGN, data);
},
/** 获取用户的标签 */
async getUserTags(accountSequence: string): Promise<UserTagAssignment[]> {
return apiClient.get(API_ENDPOINTS.USER_TAGS.USER_TAGS(accountSequence));
},
/** 获取标签下的用户列表 */
async getTagUsers(tagId: string, params: { limit?: number; offset?: number } = {}): Promise<PaginatedResponse<TagUserItem>> {
return apiClient.get(API_ENDPOINTS.USER_TAGS.TAG_USERS(tagId), { params });
},
};
export default userTagService;