feat(frontend): owner identity profile admin page

- train/owner-profiles.vue: table + create/edit dialog + enable/disable
  with confirm; project options from org snapshot with manual fallback;
  concerns limited to 500 chars with content-discipline notice
- route registered under 训练运营 with operationsConsoleRoles
This commit is contained in:
2026-08-05 03:18:50 +08:00
parent 6b8c89bcdb
commit 8e288e67e9
3 changed files with 230 additions and 0 deletions
@@ -0,0 +1,59 @@
import request from '@/utils/request';
type ApiResult<T> = { code: number; msg: string; data: T };
/**
* 业主身份档案(实时对练 persona 的运营数据),moderator 角色门,与 question-admin 同口径。
* 后端契约:/api/aihr/realtime/owner-profiles
* - GET / 列表(本租户,含 enabled/version/审计字段)
* - POST / 创建(同 projectCode 重复返回冲突)
* - PUT /{id} 全量覆盖更新(联调时若后端改乐观锁可在此集中调整)
* - POST /{id}/enable、POST /{id}/disable 启停(若后端改为 PUT /{id}/status 或全量带 enabled,
* 只需调整下方 setOwnerProfileEnabled 一处封装)
*/
export type OwnerProfile = {
id: number;
projectCode: string;
projectName: string;
ageRange: string;
incomeLevel: string;
familyStructure: string;
concerns: string;
enabled: boolean;
version?: number;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
};
export type OwnerProfilePayload = {
projectCode: string;
projectName: string;
ageRange: string;
incomeLevel: string;
familyStructure: string;
concerns: string;
};
export const listOwnerProfiles = (): Promise<ApiResult<OwnerProfile[]>> => request({
url: '/api/aihr/realtime/owner-profiles', method: 'get'
});
export const createOwnerProfile = (data: OwnerProfilePayload): Promise<ApiResult<OwnerProfile>> => request({
url: '/api/aihr/realtime/owner-profiles', method: 'post', data, headers: { repeatSubmit: false }
});
export const updateOwnerProfile = (id: number, data: OwnerProfilePayload): Promise<ApiResult<OwnerProfile>> => request({
url: `/api/aihr/realtime/owner-profiles/${id}`, method: 'put', data, headers: { repeatSubmit: false }
});
/**
* 启停统一入口:先按独立 enable/disable 端点实现;
* 若后端实际提供 PUT /{id}/status(带 enabled)或仅支持 PUT 全量带 enabled 字段,
* 联调时只改这一个函数即可。
*/
export const setOwnerProfileEnabled = (id: number, enabled: boolean): Promise<ApiResult<OwnerProfile>> => request({
url: `/api/aihr/realtime/owner-profiles/${id}/${enabled ? 'enable' : 'disable'}`, method: 'post', headers: { repeatSubmit: false }
});
+6
View File
@@ -132,6 +132,12 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'TrainTasks',
meta: { title: '任务运营', icon: 'date' }
},
{
path: 'owner-profiles',
component: () => import('@/views/train/owner-profiles.vue'),
name: 'TrainOwnerProfiles',
meta: { title: '业主身份档案', icon: 'user' }
},
{
path: 'rubrics',
component: () => import('@/views/train/rubrics.vue'),
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="owner-profiles-page">
<section class="page-head">
<div><h1>业主身份档案</h1><p>按项目维护实时对练的业主画像(年龄段、收入水平、家庭结构与关注点),启停后影响对练人设选择。</p></div>
<div class="head-actions"><el-button type="danger" :disabled="!canModerate" @click="openEdit()">新建档案</el-button><el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button></div>
</section>
<section class="panel table-panel">
<el-table v-loading="loading" :data="profiles" empty-text="暂无业主档案">
<el-table-column label="小区/项目" min-width="180"><template #default="{ row }">{{ row.projectName || row.projectCode }}<span class="muted">({{ row.projectCode }})</span></template></el-table-column>
<el-table-column prop="ageRange" label="年龄段" width="110" />
<el-table-column prop="incomeLevel" label="收入水平" width="110" />
<el-table-column prop="familyStructure" label="家庭结构" min-width="140" show-overflow-tooltip />
<el-table-column prop="concerns" label="关注点" min-width="220" show-overflow-tooltip />
<el-table-column label="启用状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column v-if="canModerate" label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link :type="row.enabled ? 'warning' : 'success'" :loading="togglingId === row.id" @click="toggleEnabled(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑业主档案' : '新建业主档案'" width="640px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="项目(小区)" required>
<el-select v-model="form.projectCode" filterable allow-create default-first-option class="full" placeholder="选择组织项目,或手输项目编码" :disabled="Boolean(form.id)" @change="onProjectChange">
<el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" />
</el-select>
</el-form-item>
<el-form-item label="项目名称" required><el-input v-model="form.projectName" maxlength="60" :disabled="Boolean(form.id)" placeholder="选择项目后自动带出,可修正" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="年龄段" required><el-input v-model="form.ageRange" maxlength="30" placeholder="如 30-45 岁" /></el-form-item>
<el-form-item label="收入水平" required><el-input v-model="form.incomeLevel" maxlength="30" placeholder="如 中等收入" /></el-form-item>
</div>
<el-form-item label="家庭结构" required><el-input v-model="form.familyStructure" maxlength="100" placeholder="如 三口之家、与老人同住" /></el-form-item>
<el-form-item label="关注点" required>
<el-input v-model="form.concerns" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="业主对物业服务的典型关注点,用于对练人设" />
<p class="concern-tip">画像属内容运营数据,避免刻板与歧视性描述</p>
</el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="submit">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup name="TrainOwnerProfiles" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { getOrgSnapshot, type OrgProjectOption } from '@/api/aihr/mobile-admin';
import { createOwnerProfile, listOwnerProfiles, setOwnerProfileEnabled, updateOwnerProfile, type OwnerProfile, type OwnerProfilePayload } from '@/api/aihr/realtime-owner-profiles';
import auth from '@/plugins/auth';
import { operationsConsoleRoles } from '@/router/access';
// 权限显示口径同 operationsConsoleRoles:本页路由 meta.roles 已限定(superadmin/hr_operator),
// 后端接口仍按 moderator 角色门二次强校验;此处仅控制操作按钮展示。
const canModerate = computed(() => auth.hasRoleOr([...operationsConsoleRoles]));
const loading = ref(false);
const saving = ref(false);
const togglingId = ref<number | null>(null);
const profiles = ref<OwnerProfile[]>([]);
const projectOptions = ref<OrgProjectOption[]>([]);
const dialogVisible = ref(false);
type OwnerProfileForm = OwnerProfilePayload & { id?: number };
const emptyForm = (): OwnerProfileForm => ({ projectCode: '', projectName: '', ageRange: '', incomeLevel: '', familyStructure: '', concerns: '' });
const form = reactive<OwnerProfileForm>(emptyForm());
const loadProfiles = async () => {
loading.value = true;
try {
profiles.value = (await listOwnerProfiles()).data || [];
} catch {
profiles.value = [];
ElMessage.error('业主档案列表读取失败');
} finally {
loading.value = false;
}
};
// 项目候选复用组织快照 projects(与任务派发下拉同一数据来源);
// 快照不可用时项目下拉允许手输 projectCode + projectName。
const loadProjects = async () => {
try {
const result = await getOrgSnapshot({ status: 'active', pageNum: 1, pageSize: 1 });
projectOptions.value = result.data?.projects || [];
} catch {
projectOptions.value = [];
}
};
const loadAll = () => Promise.all([loadProfiles(), loadProjects()]);
const onProjectChange = (code: string) => {
const matched = projectOptions.value.find((item) => item.projectCode === code);
if (matched) form.projectName = matched.projectName || matched.projectCode;
};
const openEdit = (row?: OwnerProfile) => {
Object.assign(form, emptyForm(), row || {});
dialogVisible.value = true;
};
const submit = async () => {
const payload: OwnerProfilePayload = {
projectCode: form.projectCode.trim(),
projectName: form.projectName.trim(),
ageRange: form.ageRange.trim(),
incomeLevel: form.incomeLevel.trim(),
familyStructure: form.familyStructure.trim(),
concerns: form.concerns.trim()
};
if (!payload.projectCode || !payload.projectName || !payload.ageRange || !payload.incomeLevel || !payload.familyStructure || !payload.concerns) {
ElMessage.warning('请完整填写档案内容');
return;
}
saving.value = true;
try {
if (form.id) {
await updateOwnerProfile(form.id, payload);
ElMessage.success('业主档案已更新');
} else {
await createOwnerProfile(payload);
ElMessage.success('业主档案已创建');
}
dialogVisible.value = false;
await loadProfiles();
} finally {
saving.value = false;
}
};
const toggleEnabled = async (row: OwnerProfile) => {
const target = !row.enabled;
try {
await ElMessageBox.confirm(
target ? `启用后「${row.projectName || row.projectCode}」的业主画像将用于实时对练人设,是否继续?` : `停用后「${row.projectName || row.projectCode}」的业主画像将不再用于实时对练人设,是否继续?`,
target ? '启用档案' : '停用档案',
{ confirmButtonText: target ? '确认启用' : '确认停用', cancelButtonText: '取消', type: 'warning' }
);
} catch {
return;
}
togglingId.value = row.id;
try {
await setOwnerProfileEnabled(row.id, target);
ElMessage.success(target ? '档案已启用' : '档案已停用');
await loadProfiles();
} finally {
togglingId.value = null;
}
};
onMounted(loadAll);
</script>
<style scoped>
.owner-profiles-page{padding:24px;color:#172033}.page-head,.head-actions{display:flex;gap:12px;align-items:center}.page-head{justify-content:space-between;margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.table-panel{padding:8px}.muted{color:#98a2b3;font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.full{width:100%}.concern-tip{margin:6px 0 0;color:#98a2b3;font-size:12px}@media(max-width:1100px){.owner-profiles-page{padding:16px}.page-head{align-items:flex-start}.form-grid{grid-template-columns:1fr}}
</style>