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:
@@ -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 }
|
||||||
|
});
|
||||||
@@ -132,6 +132,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||||||
name: 'TrainTasks',
|
name: 'TrainTasks',
|
||||||
meta: { title: '任务运营', icon: 'date' }
|
meta: { title: '任务运营', icon: 'date' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'owner-profiles',
|
||||||
|
component: () => import('@/views/train/owner-profiles.vue'),
|
||||||
|
name: 'TrainOwnerProfiles',
|
||||||
|
meta: { title: '业主身份档案', icon: 'user' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'rubrics',
|
path: 'rubrics',
|
||||||
component: () => import('@/views/train/rubrics.vue'),
|
component: () => import('@/views/train/rubrics.vue'),
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user