merchant.js 11 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. // 管理配置和通知均由服务端保存,浏览器内存只保留当前身份的查询结果。
  2. export function createMerchantService(ctx) {
  3. ctx.data.merchantService ||= { conversationId: null, ticketId: null, ticket: null, messages: [], owner: 'AGENT', order: null };
  4. const identity = ctx.session.merchant;
  5. const scopeKey = `${identity.tenantId}:${identity.storeId}:${identity.actorId || identity.id}`;
  6. ctx.data.merchantState ||= {};
  7. ctx.data.merchantState[scopeKey] ||= { store: null, staff: [], channel: null, ruleDraft: null, rule: null, release: null, releaseState: null, notifications: [], unread: 0 };
  8. const state = ctx.data.merchantState[scopeKey];
  9. const shared = ctx.data.merchantService;
  10. const session = () => ctx.session.merchant;
  11. const requirePermission = permission => { if (!session()?.permissions.includes(permission)) throw new Error('当前账户无权执行此操作。'); };
  12. const store = () => {
  13. if (!session()?.storeId) throw new Error('当前账户没有授权店铺,请重新登录。');
  14. return session().storeId;
  15. };
  16. const request = (method, path, body, key) => ctx.api.request(method, path, body, { product: 'merchant', ...(key ? { idempotencyKey: key } : {}) });
  17. const command = (path, body) => request('POST', path, body);
  18. const saveTicket = ticket => { shared.ticket = ticket; shared.proposal = ticket; shared.ticketId = ticket.ticket_id; return ticket; };
  19. const base = () => `/admin/v1/stores/${store()}`;
  20. const mapRule = row => row ? { ...row, days: row.window_days, products: row.product_types.map(value => value === 'keyboard' ? '键盘' : value).join(','), active: !row.revoked } : null;
  21. async function rules() { const result = await request('GET', `${base()}/exchange-rules`); state.rule = mapRule(result.current); state.ruleDraft = mapRule(result.draft); return result; }
  22. async function refreshNotifications(unreadOnly = state.notificationUnreadOnly || false) {
  23. state.notificationUnreadOnly = unreadOnly;
  24. const result = await request('GET', `/admin/v1/notifications?${new URLSearchParams({store_id:store(),limit:'100',unread_only:String(unreadOnly)})}`);
  25. state.notifications = result.items; state.unread = result.unread_count; state.notificationCursor = result.next_cursor; state.notificationSnapshot = result.snapshot_id;
  26. return result;
  27. }
  28. return {
  29. state, shared, rules, refreshNotifications,
  30. async moreNotifications() { const result = await request('GET', `/admin/v1/notifications?${new URLSearchParams({store_id:store(),limit:'100',after:String(state.notificationCursor),unread_only:String(state.notificationUnreadOnly || false)})}`); state.notifications.push(...result.items); state.notificationCursor = result.next_cursor; state.unread = result.unread_count; state.notificationSnapshot = result.snapshot_id; },
  31. async markRead(id) { const item = await request('GET', `/admin/v1/notifications/${id}`); await refreshNotifications(); return item; },
  32. async markAllRead(snapshot) { await command('/admin/v1/notifications/read-all', { store_id: store(), through_id: snapshot }); await refreshNotifications(); },
  33. async access() { state.store = await request('GET', base()); let cursor; const staff = []; do { const result = await request('GET', `${base()}/staff${cursor ? `?cursor=${cursor}` : ''}`); staff.push(...result.items); cursor = result.next_cursor; } while (cursor); state.staff = staff; },
  34. async toggleStore() { state.store = await request('PUT', `${base()}/status`, { expected_version: state.store.version, enabled: !state.store.enabled }); },
  35. async toggleStaff(row) { await request('PUT', `${base()}/staff/${row.id}/status`, { expected_version: row.version, enabled: !row.enabled }); await this.access(); },
  36. async invite(form) { await command(`${base()}/staff/invitations`, { account: form.account, role: { '客服': 'staff', '主管': 'supervisor', '商家配置管理员': 'merchant' }[form.permission] }); await this.access(); },
  37. async channels() { const result = await request('GET', `${base()}/channels`); state.channel = result.channel; state.store = result.store; },
  38. async saveChannel(form) { state.channel = await request('PUT', `${base()}/channels`, { expected_version: state.channel?.version || 0, name: form.name, callback_url: form.callback, scope_confirmed: form.scope }); },
  39. async toggleChannel() { if (!state.channel) throw new Error('请先保存渠道配置。'); state.channel = await request('PUT', `${base()}/channels/status`, { expected_version: state.channel.version, enabled: !state.channel.enabled }); },
  40. async saveRule(form) { state.ruleDraft = mapRule(await request('PUT', `${base()}/exchange-rules/draft`, { expected_version: state.ruleDraft?.version || 0, base_version: state.rule?.version || 0, window_days: Number(form.days), product_types: form.products.split(/[,,]/).map(value => value.trim()).filter(Boolean).map(value => value === '键盘' ? 'keyboard' : value), citation: form.citation })); },
  41. async releaseState() { state.releaseState = await request('GET', `${base()}/agent-releases`); state.release = state.releaseState.current; },
  42. async release(form) { state.release = await command(`${base()}/agent-releases`, { expected_version: state.release?.version || 0, candidate_hash: state.releaseState.candidate_hash, regression_checked: form.releaseChecked, reason: form.releaseReason }); await this.releaseState(); },
  43. listConversations(cursor) { return request('GET', `/openapi/v1/conversations?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? { cursor } : {}) })}`); },
  44. async conversation(id = shared.conversationId) {
  45. if (!id) return null;
  46. const conversation = await request('GET', `/openapi/v1/conversations/${id}`);
  47. // 进入其他客户的会话时清理原订单与方案,只保留已经核对归属的上下文。
  48. const previousSubject = shared.conversation?.subject_id || shared.order?.subject_id;
  49. if ((previousSubject && previousSubject !== conversation.subject_id) || (shared.order && shared.order.subject_id !== conversation.subject_id) || (!previousSubject && shared.ticket)) { shared.order = null; shared.ticket = null; shared.ticketId = null; shared.proposal = null; shared.relatedTickets = []; }
  50. shared.conversationId = id; shared.conversation = conversation; shared.owner = conversation.control_owner;
  51. const events = await request('GET', `/openapi/v1/conversations/${id}/messages?limit=100`);
  52. shared.messages = events.items;
  53. shared.messageSequence = events.items.at(-1)?.sequence || 0;
  54. shared.hasMoreMessages = shared.messageSequence < events.sequence;
  55. return conversation;
  56. },
  57. async moreMessages() {
  58. const events = await request('GET', `/openapi/v1/conversations/${shared.conversationId}/messages?limit=100&after=${shared.messageSequence || 0}`);
  59. shared.messages.push(...events.items); shared.messageSequence = events.items.at(-1)?.sequence || shared.messageSequence;
  60. shared.hasMoreMessages = shared.messageSequence < events.sequence;
  61. },
  62. async control(action) {
  63. requirePermission('conversations:manage');
  64. const result = await command(`/openapi/v1/conversations/${shared.conversationId}/${action}`, { expected_control_version: shared.conversation.control_version });
  65. shared.conversation = result.payload; shared.owner = result.payload.control_owner; return result;
  66. },
  67. async reply(text) { requirePermission('conversations:reply'); return command(`/openapi/v1/conversations/${shared.conversationId}/messages`, { text, expected_control_version: shared.conversation.control_version }); },
  68. listOrders(cursor, subjectId) { return request('GET', `/openapi/v1/orders?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? { cursor } : {}), ...(subjectId ? { subject_id: subjectId } : {}) })}`); },
  69. async order(id) { const order = await request('GET', `/openapi/v1/orders/${id}`); if (session().role === 'staff' && shared.conversation && order.subject_id !== shared.conversation.subject_id) throw new Error('订单不属于当前会话客户。'); if (shared.ticket && shared.ticket.order_id !== id) { shared.ticket = null; shared.ticketId = null; shared.proposal = null; } shared.order = order; return order; },
  70. async proposal(quantity, reason) {
  71. requirePermission('after_sales:create');
  72. const current = shared.ticket;
  73. const control = { conversation_id: shared.conversationId, expected_control_version: shared.conversation.control_version };
  74. if (current && ['DRAFT', 'WAITING_REVIEW', 'NEEDS_REVISION'].includes(current.status)) return saveTicket(await command(`/openapi/v1/after-sales/proposals/${current.proposal_id}/revisions`, { expected_version: current.version, quantity, reason, ...control }));
  75. if (!shared.order) throw new Error('请先选择客户订单。');
  76. return saveTicket(await command('/openapi/v1/after-sales/proposals', { order_id: shared.order.order_id, item_id: shared.order.item_id, quantity, reason, ...control }));
  77. },
  78. async ticket(id = shared.ticketId) { return id ? saveTicket(await request('GET', `/openapi/v1/tickets/${id}`)) : null; },
  79. tickets(cursor, subjectId) { return request('GET', `/openapi/v1/tickets?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? {cursor} : {}), ...(subjectId ? {subject_id:subjectId} : {}) })}`); },
  80. approvals(cursor) { return request('GET', `/openapi/v1/approvals?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? { cursor } : {}) })}`); },
  81. async decision(ticket, decision, reason) { requirePermission('approvals:decide'); return saveTicket(await command(`/openapi/v1/approvals/${ticket.approval_id}/decisions`, { expected_version: ticket.version, proposal_version: ticket.proposal_version, decision, reason })); },
  82. async publishRule() {
  83. const draft = state.ruleDraft;
  84. if (!draft) throw new Error('请先保存规则草稿。');
  85. const result = await command(`${base()}/exchange-rules/draft/publish`, { expected_version: draft.version, base_version: draft.base_version });
  86. await rules(); return result;
  87. },
  88. async revokeRule(reason) { const result = await command(`${base()}/exchange-rules/revoke`, { expected_version: state.rule.version, reason }); await rules(); return result; },
  89. knowledge(cursor) { return request('GET', `/admin/v1/stores/${store()}/knowledge?${new URLSearchParams({ limit: '20', ...(cursor ? { cursor } : {}) })}`); },
  90. document(id) { return request('GET', `/admin/v1/stores/${store()}/knowledge/${id}`); },
  91. saveDocument(document, form) { return request('PUT', `/admin/v1/stores/${store()}/knowledge/${document.document_id}`, { expected_version: document.version, title: form.title, text: form.text }); },
  92. transitionDocument(document, action) { return request('POST', `/admin/v1/stores/${store()}/knowledge/${document.document_id}/${action}`, { expected_version: document.version }); },
  93. billingAccount() { return request('GET', '/admin/v1/billing/account'); },
  94. billingRequests(after) { return request('GET', `/admin/v1/billing/requests${after ? `?after=${encodeURIComponent(after)}` : ''}`); },
  95. budget(account, amount, reason, id) { return request('PUT', `/admin/v1/billing/requests/${id}`, { kind: 'BUDGET', expected_version: account.policy_version, daily_limit: Math.round(Number(amount) * 1000000), reason }); },
  96. budgetDecision(item, approve, reason) { return request('POST', `/admin/v1/billing/requests/${item.id}/decisions`, { expected_version: item.version, approve, reason }); },
  97. };
  98. }