feat(aihr): complete uni-app role workflows
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type ReviewRecord = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
time: string;
|
||||
trainee: string;
|
||||
scene: string;
|
||||
score: number;
|
||||
status: string;
|
||||
summary: string;
|
||||
extPartyId: string;
|
||||
};
|
||||
|
||||
export type ReviewDetail = ReviewRecord & {
|
||||
mentorRewrite: string;
|
||||
aiComment: string;
|
||||
reviewAdvice: string;
|
||||
incentivePoint: number;
|
||||
scoreItems: Array<{ label: string; value: number | string; note: string }>;
|
||||
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string }>;
|
||||
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
|
||||
};
|
||||
|
||||
export type ReviewAdviceRequest = {
|
||||
reviewer?: string;
|
||||
advice?: string;
|
||||
incentivePoint?: number;
|
||||
nextScenarioId?: string;
|
||||
nextReason?: string;
|
||||
};
|
||||
|
||||
export type CompetencyProfile = {
|
||||
score: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
trainingMinutes: number;
|
||||
contribution: number;
|
||||
assessmentScore: number;
|
||||
aiLevel: string;
|
||||
dimensions: Array<{ label: string; value: number | string; note: string }>;
|
||||
growthPath: Array<{ level: string; progress: number; status: string; evidence: string }>;
|
||||
};
|
||||
|
||||
export type PracticeAlert = {
|
||||
type: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
severity: string;
|
||||
extPartyId: string;
|
||||
};
|
||||
|
||||
export type PracticeAssignment = {
|
||||
id: number;
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createTime: string;
|
||||
question?: string;
|
||||
referenceAnswer?: string;
|
||||
userAnswer?: string;
|
||||
feedback?: string;
|
||||
correct?: boolean;
|
||||
};
|
||||
|
||||
export type PracticeAssignmentRequest = {
|
||||
extPartyId: string;
|
||||
traineeName?: string;
|
||||
scenarioId: string;
|
||||
scenarioName?: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PromotionEvidence = {
|
||||
extPartyId: string;
|
||||
currentLevel: string;
|
||||
assessmentScore: number;
|
||||
trainingMinutes: number;
|
||||
contribution: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
dimensions: Array<{ label: string; value: number | string; note: string }>;
|
||||
latestRecords: ReviewRecord[];
|
||||
assignments: PracticeAssignment[];
|
||||
evidenceItems: string[];
|
||||
};
|
||||
|
||||
export type OrgSyncRequest = {
|
||||
dryRun?: boolean;
|
||||
replaceExisting?: boolean;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
};
|
||||
|
||||
export type OrgSyncResponse = {
|
||||
dryRun: boolean;
|
||||
replaceExisting: boolean;
|
||||
source: string;
|
||||
companyCount: number;
|
||||
departmentCount: number;
|
||||
employeeCount: number;
|
||||
syncedCount: number;
|
||||
skippedCount: number;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type OrgProjectOption = {
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
export type OrgPersonRow = {
|
||||
id: number;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
deptName: string;
|
||||
extPartyId: string;
|
||||
personName: string;
|
||||
positionName: string;
|
||||
positionLevel: string;
|
||||
employmentStatus: string;
|
||||
snapshotDate: string;
|
||||
};
|
||||
|
||||
export type OrgSnapshotParams = {
|
||||
keyword?: string;
|
||||
projectCode?: string;
|
||||
positionLevel?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type OrgSnapshotResponse = {
|
||||
total: number;
|
||||
active: number;
|
||||
departed: number;
|
||||
projects: OrgProjectOption[];
|
||||
levels: string[];
|
||||
rows: OrgPersonRow[];
|
||||
};
|
||||
|
||||
export function listPracticeReviews(): Promise<ApiResult<ReviewRecord[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/reviews',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
|
||||
return request({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function getCompetencyProfile(extPartyId?: string): Promise<ApiResult<CompetencyProfile>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/profile',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function getPromotionEvidence(extPartyId?: string): Promise<ApiResult<PromotionEvidence>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/profile/promotion-evidence',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeAlerts(): Promise<ApiResult<PracticeAlert[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/alerts',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeAssignments(extPartyId?: string): Promise<ApiResult<PracticeAssignment[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function createPracticeAssignment(data: PracticeAssignmentRequest): Promise<ApiResult<PracticeAssignment>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/org/sync',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgSnapshot(params: OrgSnapshotParams): Promise<ApiResult<OrgSnapshotResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/org/snapshot',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,19 @@ const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const demoMenuTitles = new Set(['首页', 'AI面试', '三角色对练', '案例沉淀', 'SOP知识库', '资料处理', '模型配置']);
|
||||
const demoMenuTitles = new Set([
|
||||
'首页',
|
||||
'AI面试',
|
||||
'训练运营',
|
||||
'三角色对练',
|
||||
'复盘校准',
|
||||
'案例沉淀',
|
||||
'SOP知识库',
|
||||
'资料处理',
|
||||
'模型配置',
|
||||
'组织与权限',
|
||||
'成长激励'
|
||||
]);
|
||||
const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title;
|
||||
const sidebarRouters = computed<RouteRecordRaw[]>(() =>
|
||||
permissionStore.getSidebarRoutes().filter((route) => demoMenuTitles.has(String(getRouteMenuTitle(route))))
|
||||
|
||||
@@ -92,12 +92,20 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
path: '/train',
|
||||
component: Layout,
|
||||
redirect: '/train/practice',
|
||||
alwaysShow: true,
|
||||
meta: { title: '训练运营', icon: 'peoples' },
|
||||
children: [
|
||||
{
|
||||
path: 'practice',
|
||||
component: () => import('@/views/train/practice.vue'),
|
||||
name: 'TrainPractice',
|
||||
meta: { title: '三角色对练', icon: 'peoples' }
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
component: () => import('@/views/train/reviews.vue'),
|
||||
name: 'TrainReviews',
|
||||
meta: { title: '复盘校准', icon: 'finish' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -152,6 +160,32 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Layout,
|
||||
redirect: '/admin/org-permission',
|
||||
children: [
|
||||
{
|
||||
path: 'org-permission',
|
||||
component: () => import('@/views/admin/org-permission.vue'),
|
||||
name: 'OrgPermission',
|
||||
meta: { title: '组织与权限', icon: 'tree' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/competency',
|
||||
component: Layout,
|
||||
redirect: '/competency/growth',
|
||||
children: [
|
||||
{
|
||||
path: 'growth',
|
||||
component: () => import('@/views/competency/growth.vue'),
|
||||
name: 'CompetencyGrowth',
|
||||
meta: { title: '成长激励', icon: 'chart' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: Layout,
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="org-permission-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>组织与权限</h1>
|
||||
<p>把组织人员快照、岗位映射和项目数据范围放到同一张运营视图,先支撑陪练试点权限边界。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-switch v-model="dryRun" active-text="预检" inactive-text="写入" />
|
||||
<el-button type="danger" :icon="Refresh" :loading="syncing" @click="runSync">同步预检</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article v-for="item in metrics" :key="item.label" class="metric-card">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small>{{ item.note }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel org-panel">
|
||||
<div class="panel-title">
|
||||
<h2>组织人员快照</h2>
|
||||
<div class="snapshot-stats">
|
||||
<el-tag>总数 {{ orgSnapshot?.total ?? 0 }}</el-tag>
|
||||
<el-tag type="success">在职 {{ orgSnapshot?.active ?? 0 }}</el-tag>
|
||||
<el-tag type="info">离职 {{ orgSnapshot?.departed ?? 0 }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="org-filters">
|
||||
<el-input v-model="orgFilters.keyword" clearable placeholder="姓名/岗位/部门/外部ID" @keyup.enter="loadOrgSnapshot" />
|
||||
<el-select v-model="orgFilters.projectCode" clearable placeholder="项目">
|
||||
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
||||
</el-select>
|
||||
<el-select v-model="orgFilters.positionLevel" clearable placeholder="层级">
|
||||
<el-option v-for="item in orgSnapshot?.levels || []" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="orgFilters.status" clearable placeholder="状态">
|
||||
<el-option label="在职" value="active" />
|
||||
<el-option label="离职" value="departed" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" :loading="orgLoading" @click="loadOrgSnapshot">查询</el-button>
|
||||
</div>
|
||||
<el-table v-loading="orgLoading" :data="orgSnapshot?.rows || []" size="small" empty-text="暂无组织人员快照">
|
||||
<el-table-column prop="projectName" label="项目" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="deptName" label="部门/条线" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="personName" label="姓名" width="110" />
|
||||
<el-table-column prop="positionName" label="岗位" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="positionLevel" label="层级" width="100" />
|
||||
<el-table-column prop="employmentStatus" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.employmentStatus === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.employmentStatus === 'active' ? '在职' : '离职' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="extPartyId" label="外部主体ID" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="snapshotDate" label="快照日期" width="110" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>身份三轴</h2>
|
||||
</div>
|
||||
<el-table :data="identityAxes" size="small">
|
||||
<el-table-column prop="axis" label="轴" width="120" />
|
||||
<el-table-column prop="source" label="当前数据源" width="180" />
|
||||
<el-table-column prop="example" label="例子" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>角色映射</h2>
|
||||
</div>
|
||||
<el-table :data="roleMappings" size="small">
|
||||
<el-table-column prop="match" label="匹配" min-width="150" />
|
||||
<el-table-column prop="role" label="本地角色" width="130" />
|
||||
<el-table-column prop="scope" label="数据范围" min-width="160" />
|
||||
<el-table-column prop="owner" label="负责人" width="100" />
|
||||
</el-table>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>项目范围</h2>
|
||||
</div>
|
||||
<el-table :data="projectScopes" size="small" empty-text="组织同步后展示真实项目范围">
|
||||
<el-table-column prop="project" label="项目" min-width="150" />
|
||||
<el-table-column prop="type" label="业态" width="90" />
|
||||
<el-table-column prop="supervisor" label="主管" width="100" />
|
||||
<el-table-column prop="visibleData" label="可见数据" min-width="210" />
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel sync-panel">
|
||||
<div class="panel-title">
|
||||
<h2>同步结果</h2>
|
||||
</div>
|
||||
<div v-if="syncResult" class="sync-result">
|
||||
<div>
|
||||
<span>模式</span><strong>{{ syncResult.dryRun ? '预检' : '写入' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>来源</span><strong>{{ syncResult.source }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>公司/部门/员工</span>
|
||||
<strong>{{ syncResult.companyCount }} / {{ syncResult.departmentCount }} / {{ syncResult.employeeCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>写入/跳过</span><strong>{{ syncResult.syncedCount }} / {{ syncResult.skippedCount }}</strong>
|
||||
</div>
|
||||
<p v-if="syncResult.warnings?.length">{{ syncResult.warnings.join(';') }}</p>
|
||||
<p v-else>同步接口已返回结果。</p>
|
||||
</div>
|
||||
<el-empty v-else description="尚未执行同步预检" />
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="OrgPermission" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { getOrgSnapshot, syncOrgSnapshot, type OrgSnapshotResponse, type OrgSyncResponse } from '@/api/aihr/mobile-admin';
|
||||
|
||||
const dryRun = ref(true);
|
||||
const syncing = ref(false);
|
||||
const syncResult = ref<OrgSyncResponse | null>(null);
|
||||
const orgLoading = ref(false);
|
||||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||||
const orgFilters = reactive({
|
||||
keyword: '',
|
||||
projectCode: '',
|
||||
positionLevel: '',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const metrics = [
|
||||
{ label: '权威组织源', value: '快照', note: '阶段三前用 aihr_org_snapshot' },
|
||||
{ label: '数据范围主体', value: '项目', note: '主管只看本项目训练/复盘' },
|
||||
{ label: '试点岗位', value: '管家(生活顾问)', note: 'position 标签沿用现有值' },
|
||||
{ label: 'SSO', value: '暂缓', note: '北森对接归阶段三' }
|
||||
];
|
||||
|
||||
const identityAxes = [
|
||||
{ axis: '横向条线', source: 'position_code', example: '管家/客服、工程、保洁、保安', status: '已定义', type: 'success' },
|
||||
{ axis: '纵向层级', source: '岗位/组织映射', example: '一线、主管、项目经理、片区经理', status: '最小集', type: 'warning' },
|
||||
{ axis: '管辖范围', source: '组织树 + 项目', example: '单项目 / 多项目 / 城市 / 集团', status: 'P0', type: 'danger' }
|
||||
];
|
||||
|
||||
const roleMappings = [
|
||||
{ match: 'position = 生活顾问', role: '学员', scope: '本人训练与今日安排', owner: 'HR' },
|
||||
{ match: 'position = 项目主管', role: '主管', scope: '本项目团队训练与复盘', owner: '项目经理' },
|
||||
{ match: 'role = hr_operator', role: 'HR运营', scope: '场景、Rubric、候选资料审核', owner: 'HR' },
|
||||
{ match: 'admin', role: '管理员', scope: '模型、资料处理、组织同步', owner: '技术' }
|
||||
];
|
||||
|
||||
const projectScopes = computed(() =>
|
||||
(orgSnapshot.value?.projects || []).slice(0, 8).map((item) => ({
|
||||
project: item.projectName,
|
||||
type: '住宅',
|
||||
supervisor: '按组织层级',
|
||||
visibleData: '本项目员工、训练记录、复盘与画像'
|
||||
}))
|
||||
);
|
||||
|
||||
const loadOrgSnapshot = async () => {
|
||||
orgLoading.value = true;
|
||||
try {
|
||||
const result = await getOrgSnapshot({ ...orgFilters, limit: 200 });
|
||||
orgSnapshot.value = result.data;
|
||||
} catch (error) {
|
||||
ElMessage.error('组织人员快照加载失败');
|
||||
} finally {
|
||||
orgLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
if (!dryRun.value) {
|
||||
await ElMessageBox.confirm('写入模式会覆盖当前租户组织快照,确认继续吗?', '组织同步', { type: 'warning' });
|
||||
}
|
||||
syncing.value = true;
|
||||
try {
|
||||
const result = await syncOrgSnapshot({ dryRun: dryRun.value, replaceExisting: true, pageSize: 500 });
|
||||
syncResult.value = result.data;
|
||||
ElMessage.success(dryRun.value ? '组织同步预检完成' : '组织快照已同步');
|
||||
if (!dryRun.value) {
|
||||
await loadOrgSnapshot();
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('组织同步接口不可用,请检查外部同步配置');
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadOrgSnapshot);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.org-permission-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-title h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.org-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.org-panel .panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(130px, 0.4fr)) auto;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sync-result {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: #475467;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.page-head,
|
||||
.main-grid,
|
||||
.org-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="growth-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>成长激励</h1>
|
||||
<p>展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-input v-model="extPartyId" clearable placeholder="员工手机号或 extPartyId" />
|
||||
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span>AI测评分</span>
|
||||
<strong>{{ profile?.assessmentScore ?? '-' }}</strong>
|
||||
<small>AI辅助参考,人可否决</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>当前等级</span>
|
||||
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
|
||||
<small>初/中/高级生活顾问</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>训练时长</span>
|
||||
<strong>{{ profile?.trainingMinutes ?? 0 }} 分钟</strong>
|
||||
<small>来自移动端训练记录</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>贡献度</span>
|
||||
<strong>{{ profile?.contribution ?? 0 }}</strong>
|
||||
<small>复盘学分 + 训练完成</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>能力维度</h2>
|
||||
</div>
|
||||
<div class="dimension-list">
|
||||
<div v-for="item in profile?.dimensions || []" :key="item.label" class="dimension-item">
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.note }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="Number(item.value) || 0" :stroke-width="10" color="#e60012" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>等级路径</h2>
|
||||
</div>
|
||||
<div class="stage-list">
|
||||
<div v-for="stage in profile?.growthPath || []" :key="stage.level" class="stage-item">
|
||||
<div class="stage-head">
|
||||
<strong>{{ stage.level }}</strong>
|
||||
<el-tag :type="stage.status === '已达成' ? 'success' : 'info'">{{ stage.status }}</el-tag>
|
||||
</div>
|
||||
<el-progress :percentage="stage.progress" :stroke-width="9" />
|
||||
<small>{{ stage.evidence }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>晋升证据包</h2>
|
||||
</div>
|
||||
<ul class="evidence-list">
|
||||
<li v-for="item in evidence?.evidenceItems || []" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>预警</h2>
|
||||
</div>
|
||||
<div class="alert-list">
|
||||
<div v-for="item in alerts" :key="item.title" class="alert-item" :class="`severity-${item.severity}`">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>待训任务</h2>
|
||||
<el-button type="danger" plain @click="createManualAssignment">派发专项</el-button>
|
||||
</div>
|
||||
<el-table :data="assignments" size="small">
|
||||
<el-table-column prop="traineeName" label="学员" width="110" />
|
||||
<el-table-column prop="scenarioName" label="训练" min-width="160" />
|
||||
<el-table-column prop="source" label="来源" width="92" />
|
||||
<el-table-column prop="status" label="状态" width="92" />
|
||||
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>学分规则最小集</h2>
|
||||
<el-switch v-model="leaderboardEnabled" active-text="排行榜" inactive-text="默认关闭" />
|
||||
</div>
|
||||
<el-table :data="rules" size="small">
|
||||
<el-table-column prop="source" label="来源" width="130" />
|
||||
<el-table-column prop="points" label="分值" width="90" />
|
||||
<el-table-column prop="guard" label="防刷分口径" min-width="220" />
|
||||
</el-table>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="CompetencyGrowth" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { listPracticeScenarios } from '@/api/aihr/practice';
|
||||
import {
|
||||
createPracticeAssignment,
|
||||
getCompetencyProfile,
|
||||
getPromotionEvidence,
|
||||
listPracticeAlerts,
|
||||
listPracticeAssignments,
|
||||
type CompetencyProfile,
|
||||
type PracticeAlert,
|
||||
type PracticeAssignment,
|
||||
type PromotionEvidence
|
||||
} from '@/api/aihr/mobile-admin';
|
||||
|
||||
const extPartyId = ref('');
|
||||
const loading = ref(false);
|
||||
const leaderboardEnabled = ref(false);
|
||||
const profile = ref<CompetencyProfile | null>(null);
|
||||
const evidence = ref<PromotionEvidence | null>(null);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
|
||||
const rules = [
|
||||
{ source: '训练完成', points: '+5', guard: '只认完成记录,不按点击或打开计分' },
|
||||
{ source: '主管复盘采纳', points: '+0~100', guard: '由主管复盘时人工确认' },
|
||||
{ source: '案例被采纳', points: '+20', guard: '必须审核入库并署名' },
|
||||
{ source: '课程考试通过', points: '+10', guard: '阶段二课程考试上线后启用' }
|
||||
];
|
||||
|
||||
const loadAll = async () => {
|
||||
loading.value = true;
|
||||
const party = extPartyId.value.trim();
|
||||
try {
|
||||
const [profileResult, evidenceResult, alertsResult, assignmentsResult] = await Promise.all([
|
||||
getCompetencyProfile(party),
|
||||
getPromotionEvidence(party),
|
||||
listPracticeAlerts(),
|
||||
listPracticeAssignments(party)
|
||||
]);
|
||||
profile.value = profileResult.data;
|
||||
evidence.value = evidenceResult.data;
|
||||
alerts.value = alertsResult.data || [];
|
||||
assignments.value = assignmentsResult.data || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('成长数据加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createManualAssignment = async () => {
|
||||
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
|
||||
if (!party) {
|
||||
ElMessage.warning('请先输入员工手机号或 extPartyId');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const scenarios = (await listPracticeScenarios()).data || [];
|
||||
const scenario = scenarios.find((item) => item.enabled) || scenarios[0];
|
||||
await createPracticeAssignment({
|
||||
extPartyId: party,
|
||||
traineeName: party,
|
||||
scenarioId: scenario?.id || 'fee-parking',
|
||||
scenarioName: scenario?.name,
|
||||
source: 'manual',
|
||||
reason: 'HR在成长激励页派发专项训练'
|
||||
});
|
||||
ElMessage.success('专项训练已派发');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error('专项训练派发失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.growth-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 260px auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.stage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.dimension-list,
|
||||
.stage-list,
|
||||
.alert-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dimension-item strong,
|
||||
.stage-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dimension-item span,
|
||||
.stage-item small {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.evidence-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
color: #475467;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background: #fff1f3;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.page-head,
|
||||
.main-grid,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>复盘校准</h1>
|
||||
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="review-layout">
|
||||
<article class="panel list-panel">
|
||||
<div class="panel-title">
|
||||
<h2>待复盘记录</h2>
|
||||
<el-tag type="danger">{{ reviews.length }} 条</el-tag>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="reviews" size="small" highlight-current-row @row-click="selectReview">
|
||||
<el-table-column prop="trainee" label="学员" width="96" />
|
||||
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="score" label="得分" width="70" />
|
||||
<el-table-column prop="status" label="状态" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '待复盘' ? 'warning' : 'success'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="时间" width="128" />
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel detail-panel">
|
||||
<template v-if="detail">
|
||||
<div class="panel-title">
|
||||
<h2>{{ detail.trainee }} · {{ detail.scene }}</h2>
|
||||
<el-tag :type="detail.score >= 80 ? 'success' : 'danger'">{{ detail.score }} 分</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="score-grid">
|
||||
<div v-for="item in detail.scoreItems" :key="item.label" class="score-card">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small>{{ item.note }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialogue-list">
|
||||
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
|
||||
<strong>{{ turn.label }}</strong>
|
||||
<p>{{ turn.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annotation-list">
|
||||
<h3>逐句标注</h3>
|
||||
<el-table :data="detail.annotations" size="small" empty-text="暂无逐句标注">
|
||||
<el-table-column prop="sourceText" label="原句" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="issue" label="问题" min-width="160" />
|
||||
<el-table-column prop="rewrite" label="推荐改写" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="sopPoint" label="SOP点" min-width="160" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-form class="review-form" label-position="top">
|
||||
<el-form-item label="复盘建议">
|
||||
<el-input v-model="reviewForm.advice" type="textarea" :rows="3" placeholder="写给员工的复盘建议" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="贡献学分">
|
||||
<el-input-number v-model="reviewForm.incentivePoint" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下次训练">
|
||||
<el-select v-model="reviewForm.nextScenarioId" clearable placeholder="不派发">
|
||||
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="派发原因">
|
||||
<el-input v-model="reviewForm.nextReason" placeholder="例如:低分维度定向再练" />
|
||||
</el-form-item>
|
||||
<div class="form-actions">
|
||||
<el-button type="danger" :loading="saving" @click="submitReview">标记已复盘</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
<el-empty v-else description="选择一条待复盘记录" />
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainReviews" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import { getPracticeReview, listPracticeReviews, markPracticeReviewed, type ReviewDetail, type ReviewRecord } from '@/api/aihr/mobile-admin';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const reviews = ref<ReviewRecord[]>([]);
|
||||
const detail = ref<ReviewDetail | null>(null);
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
|
||||
const reviewForm = reactive({
|
||||
reviewer: '主管',
|
||||
advice: '',
|
||||
incentivePoint: 10,
|
||||
nextScenarioId: '',
|
||||
nextReason: ''
|
||||
});
|
||||
|
||||
const loadScenarios = async () => {
|
||||
try {
|
||||
const result = await listPracticeScenarios();
|
||||
scenarios.value = result.data || [];
|
||||
} catch (error) {
|
||||
scenarios.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviews = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await listPracticeReviews();
|
||||
reviews.value = result.data || [];
|
||||
if (!detail.value && reviews.value.length > 0) {
|
||||
await selectReview(reviews.value[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('待复盘列表加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectReview = async (row: ReviewRecord) => {
|
||||
const result = await getPracticeReview(row.id);
|
||||
detail.value = result.data;
|
||||
reviewForm.advice = result.data.reviewAdvice || '';
|
||||
reviewForm.incentivePoint = result.data.incentivePoint || 10;
|
||||
reviewForm.nextScenarioId = '';
|
||||
reviewForm.nextReason = '';
|
||||
};
|
||||
|
||||
const submitReview = async () => {
|
||||
if (!detail.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await markPracticeReviewed(detail.value.id, {
|
||||
reviewer: reviewForm.reviewer,
|
||||
advice: reviewForm.advice,
|
||||
incentivePoint: reviewForm.incentivePoint,
|
||||
nextScenarioId: reviewForm.nextScenarioId,
|
||||
nextReason: reviewForm.nextReason
|
||||
});
|
||||
ElMessage.success('已完成复盘处理');
|
||||
detail.value = null;
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
ElMessage.error('复盘提交失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadScenarios();
|
||||
loadReviews();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.review-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel,
|
||||
.score-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.review-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.score-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
padding: 12px;
|
||||
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialogue-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialogue-item {
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #475467;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation-list {
|
||||
margin-top: 18px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.review-layout,
|
||||
.score-grid,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user