fix: 修复客服权限与收件箱配置问题

This commit is contained in:
Rogee
2026-08-08 19:52:18 +08:00
parent dc2a6577bf
commit 518f9c5d6b
20 changed files with 385 additions and 70 deletions
@@ -13,6 +13,17 @@ describe('#routeIsAccessibleFor', () => {
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false);
expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true);
});
it('allows agents to open the help center entry route', () => {
const route = {
name: 'portals_index',
meta: {
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
};
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(true);
});
});
describe('#defaultRedirectPage', () => {
@@ -104,6 +104,7 @@
"RESET_DESC": "Copy this new password now and share it with the agent securely. The old password no longer works, and this password cannot be shown again after closing.",
"COPY": "Copy password",
"COPY_SUCCESS": "Password copied to clipboard",
"COPY_FAILED": "Unable to copy the password. Please select and copy it manually.",
"DONE": "I have saved it"
},
"SEARCH_PLACEHOLDER": "Search agents...",
@@ -393,6 +393,12 @@
"USERNAME": "Login username",
"PASSWORD": "Login password",
"DESIRED_PRESENCE": "Initial presence",
"PRESENCE": {
"ONLINE": "Online",
"BUSY": "Busy",
"AWAY": "Away",
"OFFLINE": "Offline"
},
"WEBHOOK_URL": "Connector webhook URL",
"SUBMIT_BUTTON": "Create Shangwutong channel",
"API": {
@@ -1180,6 +1186,45 @@
"REFRESH": "Refresh status",
"DESIRED_PRESENCE": "Desired presence",
"DESIRED_PRESENCE_HELP": "Changes are applied online. Offline logs out only this Shangwutong account.",
"PRESENCE": {
"ONLINE": "Online",
"BUSY": "Busy",
"AWAY": "Away",
"OFFLINE": "Offline"
},
"STATUS_FIELDS": {
"CONNECTION_STATUS": "Connection status",
"ACTUAL_PRESENCE": "Current presence",
"CREDENTIAL_STATUS": "Credential status",
"LAST_HEARTBEAT_AT": "Last heartbeat"
},
"STATUS_VALUES": {
"UNKNOWN": "Unknown",
"CONNECTION": {
"PENDING": "Pending",
"LOGGING_IN": "Logging in",
"CONNECTED": "Connected",
"DEGRADED": "Degraded",
"RELOGIN_REQUIRED": "Relogin required",
"VERIFICATION_REQUIRED": "Verification required",
"AUTH_FAILED": "Authentication failed",
"DISABLED": "Disabled",
"OFFLINE": "Offline"
},
"PRESENCE": {
"ONLINE": "Online",
"BUSY": "Busy",
"AWAY": "Away",
"OFFLINE": "Offline"
},
"CREDENTIAL": {
"PENDING": "Pending verification",
"VERIFYING": "Verifying",
"APPLIED": "Applied",
"REJECTED": "Rejected",
"VERIFICATION_REQUIRED": "Verification required"
}
},
"PASSWORD": "Replace password",
"PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.",
"WEBHOOK_URL": "Connector webhook URL",
@@ -104,6 +104,7 @@
"RESET_DESC": "请立即复制并安全地将此新密码交给客服。旧密码已失效,关闭后将无法再次查看。",
"COPY": "复制密码",
"COPY_SUCCESS": "密码已复制到剪贴板",
"COPY_FAILED": "无法复制密码,请手动选择并复制。",
"DONE": "我已保存"
},
"SEARCH_PLACEHOLDER": "搜索客服代表...",
@@ -393,6 +393,12 @@
"USERNAME": "登录账号",
"PASSWORD": "登录密码",
"DESIRED_PRESENCE": "初始在线状态",
"PRESENCE": {
"ONLINE": "在线",
"BUSY": "忙碌",
"AWAY": "离开",
"OFFLINE": "离线"
},
"WEBHOOK_URL": "Connector Webhook 地址",
"SUBMIT_BUTTON": "创建商务通渠道",
"API": {
@@ -838,50 +844,50 @@
},
"ASSIGNMENT": {
"TITLE": "对话分配",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"DESCRIPTION": "根据分配策略将新对话自动分配给可用客服",
"ENABLE_AUTO_ASSIGNMENT": "启用自动分配对话",
"DEFAULT_RULES_TITLE": "默认分配规则",
"DEFAULT_RULES_DESCRIPTION": "所有对话使用默认分配方式",
"DEFAULT_RULE_1": "优先分配最早创建的对话",
"DEFAULT_RULE_2": "轮询分配",
"CUSTOMIZE_WITH_POLICY": "使用分配策略自定义",
"USING_POLICY": "此收件箱正在使用自定义分配策略",
"CUSTOMIZE_POLICY": "自定义分配策略",
"DELETE_POLICY": "删除策略",
"POLICY_LABEL": "分配策略",
"ASSIGNMENT_ORDER_LABEL": "分配顺序",
"ASSIGNMENT_METHOD_LABEL": "分配方式",
"POLICY_STATUS": {
"ACTIVE": "状态",
"INACTIVE": "Inactive"
"ACTIVE": "启用",
"INACTIVE": "停用"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
"EARLIEST_CREATED": "最早创建",
"LONGEST_WAITING": "等待时间最长"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
"ROUND_ROBIN": "轮询分配",
"BALANCED": "均衡分配"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
"UPGRADE_PROMPT": "自定义分配策略适用于商业版套餐",
"UPGRADE_TO_BUSINESS": "升级到商业版",
"DEFAULT_POLICY_LINKED": "已关联默认策略",
"DEFAULT_POLICY_DESCRIPTION": "关联自定义分配策略,以配置此收件箱如何将对话分配给客服。",
"LINK_EXISTING_POLICY": "关联现有策略",
"CREATE_NEW_POLICY": "创建新策略",
"NO_POLICIES": "未找到分配策略",
"VIEW_ALL_POLICIES": "查看全部策略",
"CURRENT_BEHAVIOR": "当前使用默认分配方式:",
"LINK_SUCCESS": "分配策略关联成功",
"LINK_ERROR": "分配策略关联失败"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"DELETE_CONFIRM_TITLE": "删除分配策略?",
"DELETE_CONFIRM_MESSAGE": "确定要从此收件箱移除该分配策略吗?移除后,收件箱将恢复使用默认分配规则。",
"CANCEL": "取消",
"CONFIRM_DELETE": "删除",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
"DELETE_SUCCESS": "分配策略已移除",
"DELETE_ERROR": "移除分配策略失败"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "重新授权",
@@ -1175,15 +1181,54 @@
}
},
"SHANGWUTONG_SETTINGS": {
"RUNTIME_STATUS": "Connector 运行状态",
"RUNTIME_STATUS_HELP": "显示 Connector 最近回报的连接、在线与凭据状态。",
"RUNTIME_STATUS": "连接器运行状态",
"RUNTIME_STATUS_HELP": "显示连接器最近回报的连接、在线与凭据状态。",
"REFRESH": "刷新状态",
"DESIRED_PRESENCE": "期望在线状态",
"DESIRED_PRESENCE_HELP": "状态在线切换;选择 offline 只会退出当前商务通账号。",
"DESIRED_PRESENCE_HELP": "状态在线切换;选择离线只会退出当前商务通账号。",
"PRESENCE": {
"ONLINE": "在线",
"BUSY": "忙碌",
"AWAY": "离开",
"OFFLINE": "离线"
},
"STATUS_FIELDS": {
"CONNECTION_STATUS": "连接状态",
"ACTUAL_PRESENCE": "当前在线状态",
"CREDENTIAL_STATUS": "凭据状态",
"LAST_HEARTBEAT_AT": "最后心跳时间"
},
"STATUS_VALUES": {
"UNKNOWN": "未知",
"CONNECTION": {
"PENDING": "等待连接",
"LOGGING_IN": "正在登录",
"CONNECTED": "已连接",
"DEGRADED": "连接异常",
"RELOGIN_REQUIRED": "需要重新登录",
"VERIFICATION_REQUIRED": "需要验证",
"AUTH_FAILED": "认证失败",
"DISABLED": "已禁用",
"OFFLINE": "已离线"
},
"PRESENCE": {
"ONLINE": "在线",
"BUSY": "忙碌",
"AWAY": "离开",
"OFFLINE": "离线"
},
"CREDENTIAL": {
"PENDING": "等待验证",
"VERIFYING": "正在验证",
"APPLIED": "已生效",
"REJECTED": "验证失败",
"VERIFICATION_REQUIRED": "需要验证"
}
},
"PASSWORD": "修改密码",
"PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。",
"WEBHOOK_URL": "Connector Webhook 地址",
"WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个 Connector 地址。"
"WEBHOOK_URL": "连接器 Webhook 地址",
"WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个连接器地址。"
},
"CHANNELS": {
"MESSENGER": "Messenger",
@@ -322,7 +322,7 @@
"CAPTAIN_INBOXES": "收件箱",
"CAPTAIN_SETTINGS": "设置",
"HOME": "首页",
"AGENTS": "客服代理",
"AGENTS": "客服",
"AGENT_BOTS": "机器人",
"AUDIT_LOGS": "审计日志",
"INBOXES": "收件箱",
@@ -102,10 +102,7 @@ const portalRoutes = [
{
path: getPortalRoute(':navigationPath'),
name: 'portals_index',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
meta,
component: PortalsIndex,
},
];
@@ -0,0 +1,22 @@
import helpcenterRoutes from './helpcenter.routes';
const findRoute = name => {
const [rootRoute] = helpcenterRoutes.routes;
return rootRoute.children.find(route => route.name === name);
};
describe('Help center routes', () => {
it('allows agents to enter the help center router', () => {
expect(findRoute('portals_index').meta.permissions).toContain('agent');
});
it('keeps article routes accessible to agents', () => {
expect(findRoute('portals_articles_index').meta.permissions).toContain(
'agent'
);
});
it('keeps portal creation restricted to administrators or custom roles', () => {
expect(findRoute('portals_new').meta.permissions).not.toContain('agent');
});
});
@@ -29,8 +29,12 @@ const open = () => dialogRef.value?.open();
const close = () => dialogRef.value?.close();
const copyPassword = async password => {
await copyTextToClipboard(password);
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS'));
try {
await copyTextToClipboard(password);
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS'));
} catch {
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_FAILED'));
}
};
defineExpose({ open, close });
@@ -143,10 +143,18 @@ export default {
<label>
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.DESIRED_PRESENCE') }}
<select v-model="desiredPresence">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
<option value="online">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.ONLINE') }}
</option>
<option value="busy">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.BUSY') }}
</option>
<option value="away">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.AWAY') }}
</option>
<option value="offline">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PRESENCE.OFFLINE') }}
</option>
</select>
</label>
@@ -32,6 +32,13 @@ export default {
this.fetchHealth();
},
methods: {
statusLabel(type, value, fallback) {
const status = value || fallback;
const key = `INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.${type}.${status.toUpperCase()}`;
return this.$te(key)
? this.$t(key)
: this.$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_VALUES.UNKNOWN');
},
async fetchHealth() {
this.isLoadingHealth = true;
try {
@@ -88,43 +95,65 @@ export default {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
connection_status
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CONNECTION_STATUS'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.connection_status ||
inbox.connection_status ||
'pending'
statusLabel(
'CONNECTION',
health?.connection_status || inbox.connection_status,
'pending'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
actual_presence
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.ACTUAL_PRESENCE'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.actual_presence ||
inbox.actual_presence ||
'offline'
statusLabel(
'PRESENCE',
health?.actual_presence || inbox.actual_presence,
'offline'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
credential_status
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.CREDENTIAL_STATUS'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.credential_status ||
inbox.credential_status ||
'pending'
statusLabel(
'CREDENTIAL',
health?.credential_status || inbox.credential_status,
'pending'
)
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
last_heartbeat_at
{{
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.STATUS_FIELDS.LAST_HEARTBEAT_AT'
)
}}
</p>
<p class="text-body-main text-n-slate-12">
{{
@@ -156,10 +185,18 @@ export default {
"
>
<select v-model="desiredPresence" class="w-full">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
<option value="online">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.ONLINE') }}
</option>
<option value="busy">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.BUSY') }}
</option>
<option value="away">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.AWAY') }}
</option>
<option value="offline">
{{ $t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.OFFLINE') }}
</option>
</select>
</SettingsFieldSection>
@@ -33,6 +33,7 @@ export const copyTextToClipboard = async data => {
document.body.appendChild(textarea);
try {
textarea.focus({ preventScroll: true });
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
@@ -2,6 +2,7 @@ import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
const mockWriteText = vi.fn();
const mockExecCommand = vi.fn();
const originalCreateElement = document.createElement.bind(document);
const setClipboard = clipboard => {
Object.defineProperty(navigator, 'clipboard', {
@@ -148,9 +149,16 @@ describe('copyTextToClipboard', () => {
it('falls back when clipboard API is not available', async () => {
setClipboard(undefined);
const focus = vi.fn();
vi.spyOn(document, 'createElement').mockImplementation(tagName => {
const element = originalCreateElement(tagName);
if (tagName === 'textarea') element.focus = focus;
return element;
});
await copyTextToClipboard('test');
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(mockExecCommand).toHaveBeenCalledWith('copy');
expect(document.querySelector('textarea')).toBeNull();
});