feat(supply-chain): add store account exports and permissions
This commit is contained in:
parent
bf85258d3c
commit
571b840911
@ -0,0 +1,62 @@
|
||||
export {}
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockCreateWorkbook = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/store-account-management-export.service', () => ({
|
||||
storeAccountManagementExportService: {
|
||||
createWorkbook: (...args: unknown[]) => mockCreateWorkbook(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { GET } = require('./route')
|
||||
|
||||
describe('GET /api/supply-chain/store-account-management/export', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
|
||||
requestId: 'req-export',
|
||||
user: { id: 'management-1', name: '经管人员', role: 'USER', businessRoles: ['MANAGEMENT'] },
|
||||
}))
|
||||
mockCreateWorkbook.mockResolvedValue({
|
||||
content: Buffer.from('xlsx-content'),
|
||||
fileName: '店铺账号管理-已选2条.xlsx',
|
||||
count: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes selected identifiers to the export service and returns a private XLSX download', async () => {
|
||||
const request = {
|
||||
nextUrl: new URL('http://localhost/api/supply-chain/store-account-management/export?scope=selected&ids=account-1,account-2'),
|
||||
}
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true })
|
||||
expect(mockCreateWorkbook).toHaveBeenCalledWith({
|
||||
ids: ['account-1', 'account-2'],
|
||||
user: expect.objectContaining({ id: 'management-1' }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('spreadsheetml.sheet')
|
||||
expect(response.headers.get('Cache-Control')).toContain('no-store')
|
||||
expect(response.headers.get('Content-Disposition')).toContain(encodeURIComponent('店铺账号管理-已选2条.xlsx'))
|
||||
})
|
||||
|
||||
it('omits identifiers for a full export', async () => {
|
||||
const request = {
|
||||
nextUrl: new URL('http://localhost/api/supply-chain/store-account-management/export'),
|
||||
}
|
||||
|
||||
await GET(request)
|
||||
|
||||
expect(mockCreateWorkbook).toHaveBeenCalledWith({
|
||||
ids: undefined,
|
||||
user: expect.objectContaining({ id: 'management-1' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { handleRequest } from '@/server/core/response'
|
||||
import { storeAccountManagementExportService } from '@/server/services/store-account-management-export.service'
|
||||
|
||||
function parseSelectedIds(request: NextRequest) {
|
||||
if (request.nextUrl.searchParams.get('scope') !== 'selected') return undefined
|
||||
return (request.nextUrl.searchParams.get('ids') ?? '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const payload = await storeAccountManagementExportService.createWorkbook({
|
||||
ids: parseSelectedIds(request),
|
||||
user: ctx.user,
|
||||
})
|
||||
return new NextResponse(new Uint8Array(payload.content), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(payload.fileName)}`,
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Download, ListChecks } from 'lucide-react'
|
||||
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
|
||||
|
||||
function getDownloadFileName(response: Response) {
|
||||
const header = response.headers.get('Content-Disposition') ?? ''
|
||||
const encoded = header.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|
||||
if (!encoded) return '店铺账号管理.xlsx'
|
||||
try {
|
||||
return decodeURIComponent(encoded)
|
||||
} catch {
|
||||
return '店铺账号管理.xlsx'
|
||||
}
|
||||
}
|
||||
|
||||
async function readError(response: Response) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
return payload?.error?.message || '导出失败'
|
||||
}
|
||||
|
||||
export function StoreAccountExportMenu({ selectedIds }: { selectedIds: string[] }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutside = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', closeOnOutside)
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutside)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const download = async (scope: 'selected' | 'all') => {
|
||||
if (scope === 'selected' && selectedIds.length === 0) return
|
||||
if (scope === 'selected' && selectedIds.length > STORE_ACCOUNT_EXPORT_SELECTION_LIMIT) {
|
||||
window.alert(`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号,可分批导出或使用导出全部`)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
setDownloading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (scope === 'selected') {
|
||||
params.set('scope', 'selected')
|
||||
params.set('ids', selectedIds.join(','))
|
||||
}
|
||||
const query = params.size > 0 ? `?${params}` : ''
|
||||
const response = await fetch(`/api/supply-chain/store-account-management/export${query}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) throw new Error(await readError(response))
|
||||
const url = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = getDownloadFileName(response)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : '导出失败')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="store-account-export-menu">
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloading}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Download size={15} /> {downloading ? '导出中' : '导出'} <ChevronDown size={13} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="store-account-export-options" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="store-account-export-option"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => void download('selected')}
|
||||
>
|
||||
<ListChecks size={15} />
|
||||
<span><b>导出已选</b><small>{selectedIds.length > 0 ? `已选择 ${selectedIds.length} 条` : '请先勾选账号'}</small></span>
|
||||
</button>
|
||||
<button type="button" className="store-account-export-option" onClick={() => void download('all')}>
|
||||
<Download size={15} />
|
||||
<span><b>导出全部</b><small>不受当前筛选和分页影响</small></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,9 +8,10 @@ import { StoreAccountBatchUpdateDialog } from './StoreAccountBatchUpdateDialog'
|
||||
import { StoreAccountImportProgress } from './StoreAccountImportProgress'
|
||||
import { StoreAccountLogDialog } from './StoreAccountLogDialog'
|
||||
import { StoreAccountPermissionDialog } from './StoreAccountPermissionDialog'
|
||||
import { StoreAccountExportMenu } from './StoreAccountExportMenu'
|
||||
import { useStoreAccountImport } from './useStoreAccountImport'
|
||||
|
||||
type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canManagePermissions: boolean }
|
||||
type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canExport: boolean; canManagePermissions: boolean }
|
||||
type Draft = Partial<Record<StoreAccountFieldKey, string>> & { id?: string }
|
||||
|
||||
async function readResponse(response: Response) {
|
||||
@ -42,7 +43,7 @@ function rowToDraft(row: StoreAccountRow): Draft {
|
||||
}
|
||||
}
|
||||
|
||||
export function StoreAccountManagementView({ initialData, initialFilters, canEdit, canManagePermissions }: Props) {
|
||||
export function StoreAccountManagementView({ initialData, initialFilters, canEdit, canExport, canManagePermissions }: Props) {
|
||||
const [data, setData] = useState(initialData)
|
||||
const [filters, setFilters] = useState(initialFilters)
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
@ -60,6 +61,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
|
||||
const batchFields = useMemo(() => STORE_ACCOUNT_FIELDS
|
||||
.filter((field) => !field.system && data.fieldAccess[field.key] === 'EDITABLE' && field.key !== 'password')
|
||||
.map((field) => ({ key: field.key as StoreAccountBatchEditableFieldKey, label: field.label })), [data.fieldAccess])
|
||||
const canSelectRows = canEdit || canExport
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchFields.some((field) => field.key === batchField) && batchFields[0]) setBatchField(batchFields[0].key)
|
||||
@ -143,7 +145,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
|
||||
return <div className="store-account-page">
|
||||
<header className="store-account-toolbar">
|
||||
<div><h1>店铺账号管理</h1><p>人工维护店铺账号、负责人和聚水潭服务信息</p></div>
|
||||
<div className="store-account-actions"><span>共 {data.pageInfo.total} 条</span>{canManagePermissions && <button type="button" onClick={() => setPermissionsOpen(true)} title="字段权限"><Settings2 size={15} /> 字段权限</button>}{canEdit && <><button type="button" disabled={selectedIds.length === 0} onClick={() => { setBatchValue(''); setBatchOpen(true) }}><KeyRound size={15} /> 批量修改{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}</button><button type="button" onClick={() => void downloadTemplate()}><FileDown size={15} /> 下载模板</button><button type="button" disabled={storeAccountImport.isImporting} onClick={() => fileRef.current?.click()}><Upload size={15} /> {storeAccountImport.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(e) => { const file = e.target.files?.[0]; if (file) void storeAccountImport.importFile(file); e.currentTarget.value = '' }} /><button type="button" className="primary" onClick={() => setDraft({ accountType: 'MAIN' })}><Plus size={15} /> 新增账号</button></>}</div>
|
||||
<div className="store-account-actions"><span>共 {data.pageInfo.total} 条</span>{canExport && <StoreAccountExportMenu selectedIds={selectedIds} />}{canManagePermissions && <button type="button" onClick={() => setPermissionsOpen(true)} title="字段权限"><Settings2 size={15} /> 字段权限</button>}{canEdit && <><button type="button" disabled={selectedIds.length === 0} onClick={() => { setBatchValue(''); setBatchOpen(true) }}><KeyRound size={15} /> 批量修改{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}</button><button type="button" onClick={() => void downloadTemplate()}><FileDown size={15} /> 下载模板</button><button type="button" disabled={storeAccountImport.isImporting} onClick={() => fileRef.current?.click()}><Upload size={15} /> {storeAccountImport.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(e) => { const file = e.target.files?.[0]; if (file) void storeAccountImport.importFile(file); e.currentTarget.value = '' }} /><button type="button" className="primary" onClick={() => setDraft({ accountType: 'MAIN' })}><Plus size={15} /> 新增账号</button></>}</div>
|
||||
</header>
|
||||
<form className="store-account-filters" onSubmit={(e) => { e.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}>
|
||||
<input value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} placeholder="批量搜索,多个关键词用逗号分隔" />
|
||||
@ -161,7 +163,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
|
||||
<input value={filters.accountGroupName} onChange={(e) => setFilters({ ...filters, accountGroupName: e.target.value })} placeholder="账号分组" />
|
||||
<button type="submit" className="primary">查询</button><button type="button" className="icon-button" title="重置筛选" aria-label="重置筛选" onClick={resetFilters}><RotateCcw size={15} /></button>
|
||||
</form>
|
||||
<div className="store-account-table-wrap"><table><thead><tr>{canEdit && <th className="select-column"><input type="checkbox" aria-label="全选当前页" checked={data.rows.length > 0 && data.rows.every((row) => selectedIds.includes(row.id))} onChange={(e) => setSelectedIds(e.target.checked ? Array.from(new Set([...selectedIds, ...data.rows.map((row) => row.id)])) : selectedIds.filter((id) => !data.rows.some((row) => row.id === id)))} /></th>}{visibleFields.map((field) => <th key={field.key}>{field.label}</th>)}<th>操作</th></tr></thead><tbody>{loading ? <tr><td colSpan={visibleFields.length + (canEdit ? 2 : 1)} className="empty">加载中...</td></tr> : data.rows.length === 0 ? <tr><td colSpan={visibleFields.length + (canEdit ? 2 : 1)} className="empty">暂无店铺账号</td></tr> : data.rows.map((row) => <tr key={row.id}>{canEdit && <td className="select-column"><input type="checkbox" aria-label={`选择${row.storeName}`} checked={selectedIds.includes(row.id)} onChange={(e) => setSelectedIds(e.target.checked ? [...selectedIds, row.id] : selectedIds.filter((id) => id !== row.id))} /></td>}{visibleFields.map((field) => <td key={field.key}>{field.key === 'accountType' ? formatStoreAccountType(row.accountType) : field.key === 'password' ? <span className="password-cell">{revealedPasswords[row.id] !== undefined ? revealedPasswords[row.id] || '未设置' : row.passwordConfigured ? '••••••' : '未设置'}{row.passwordConfigured && <button type="button" title={revealedPasswords[row.id] !== undefined ? '隐藏密码' : '查看密码'} onClick={() => void togglePassword(row)}>{revealedPasswords[row.id] !== undefined ? <EyeOff size={14} /> : <Eye size={14} />}</button>}</span> : field.key === 'createdAt' || field.key === 'updatedAt' ? formatTime(row[field.key]) : String(row[field.key as keyof StoreAccountRow] ?? '-')}</td>)}<td className="row-actions">{canEdit && <button type="button" onClick={() => setDraft(rowToDraft(row))}><KeyRound size={14} /> 编辑</button>}<button type="button" onClick={() => void openLogs(row)}><History size={14} /> 日志</button></td></tr>)}</tbody></table></div>
|
||||
<div className="store-account-table-wrap"><table><thead><tr>{canSelectRows && <th className="select-column"><input type="checkbox" aria-label="全选当前页" checked={data.rows.length > 0 && data.rows.every((row) => selectedIds.includes(row.id))} onChange={(e) => setSelectedIds(e.target.checked ? Array.from(new Set([...selectedIds, ...data.rows.map((row) => row.id)])) : selectedIds.filter((id) => !data.rows.some((row) => row.id === id)))} /></th>}{visibleFields.map((field) => <th key={field.key}>{field.label}</th>)}<th>操作</th></tr></thead><tbody>{loading ? <tr><td colSpan={visibleFields.length + (canSelectRows ? 2 : 1)} className="empty">加载中...</td></tr> : data.rows.length === 0 ? <tr><td colSpan={visibleFields.length + (canSelectRows ? 2 : 1)} className="empty">暂无店铺账号</td></tr> : data.rows.map((row) => <tr key={row.id}>{canSelectRows && <td className="select-column"><input type="checkbox" aria-label={`选择${row.storeName}`} checked={selectedIds.includes(row.id)} onChange={(e) => setSelectedIds(e.target.checked ? [...selectedIds, row.id] : selectedIds.filter((id) => id !== row.id))} /></td>}{visibleFields.map((field) => <td key={field.key}>{field.key === 'accountType' ? formatStoreAccountType(row.accountType) : field.key === 'password' ? <span className="password-cell">{revealedPasswords[row.id] !== undefined ? revealedPasswords[row.id] || '未设置' : row.passwordConfigured ? '••••••' : '未设置'}{row.passwordConfigured && <button type="button" title={revealedPasswords[row.id] !== undefined ? '隐藏密码' : '查看密码'} onClick={() => void togglePassword(row)}>{revealedPasswords[row.id] !== undefined ? <EyeOff size={14} /> : <Eye size={14} />}</button>}</span> : field.key === 'createdAt' || field.key === 'updatedAt' ? formatTime(row[field.key]) : String(row[field.key as keyof StoreAccountRow] ?? '-')}</td>)}<td className="row-actions">{canEdit && <button type="button" onClick={() => setDraft(rowToDraft(row))}><KeyRound size={14} /> 编辑</button>}<button type="button" onClick={() => void openLogs(row)}><History size={14} /> 日志</button></td></tr>)}</tbody></table></div>
|
||||
<footer className="store-account-pagination">
|
||||
<span className="store-account-page-total">{loading ? '正在统计...' : `共 ${data.pageInfo.total} 条`}</span>
|
||||
<button className="store-account-page-btn" type="button" disabled={data.pageInfo.page <= 1 || loading} onClick={() => void load(filters, data.pageInfo.page - 1)}><ArrowLeft size={12} strokeWidth={1.8} />上一页</button>
|
||||
@ -310,4 +312,5 @@ const styles = `
|
||||
.store-account-filters .store-name-clear{border:1px solid #d4d4d8;background:#fff;color:#52525b}
|
||||
.store-account-filters .store-name-apply{border:1px solid #27272a;background:#27272a;color:#fff}
|
||||
.store-account-actions button:disabled{opacity:.45;cursor:not-allowed}
|
||||
.store-account-export-menu{position:relative}.store-account-export-options{position:absolute;top:calc(100% + 7px);right:0;z-index:90;width:230px;padding:6px;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.12)}.store-account-actions .store-account-export-option{display:flex;width:100%;height:auto;min-height:48px;justify-content:flex-start;gap:9px;padding:7px 9px;border:0;border-radius:4px;background:#fff;text-align:left}.store-account-actions .store-account-export-option:hover:not(:disabled){background:#f4f4f5}.store-account-actions .store-account-export-option>span{display:flex;min-width:0;flex-direction:column;gap:2px}.store-account-actions .store-account-export-option b{color:#27272a;font-size:12px;font-weight:600}.store-account-actions .store-account-export-option small{color:#71717a;font-size:10px;font-weight:400}.store-account-actions .store-account-export-option:disabled{opacity:.45}
|
||||
`
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
NO_STORE_ACCOUNT_PAGE_ACCESS,
|
||||
resolveStoreAccountUserPageAccess,
|
||||
} from './storeAccountPermissionScope'
|
||||
import { saveStoreAccountFieldPermissionsAction } from './actions'
|
||||
|
||||
type PermissionSettings = {
|
||||
permissions: StoreAccountFieldPermissionRecord[]
|
||||
@ -173,11 +174,8 @@ export function StoreAccountPermissionDialog({
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates }),
|
||||
}))
|
||||
const result = await saveStoreAccountFieldPermissionsAction({ updates })
|
||||
if (!result.success) throw new Error(result.message)
|
||||
await loadSettings()
|
||||
await onSaved()
|
||||
setMessage('权限已保存')
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
'use server'
|
||||
|
||||
import { z } from 'zod'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { isStoreAccountFieldKey } from '@/lib/store-account-management'
|
||||
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||
|
||||
const schema = z.object({
|
||||
updates: z.array(z.object({
|
||||
fieldKey: z.string(),
|
||||
subjectKey: z.string().trim().min(1).max(120),
|
||||
visibility: z.enum(['HIDDEN', 'READONLY', 'EDITABLE']).nullable(),
|
||||
}).strict()).min(1).max(500),
|
||||
}).strict()
|
||||
|
||||
export async function saveStoreAccountFieldPermissionsAction(input: unknown) {
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success || parsed.data.updates.some((item) => !isStoreAccountFieldKey(item.fieldKey))) {
|
||||
return { success: false as const, message: '字段权限内容不正确' }
|
||||
}
|
||||
try {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const data = await storeAccountManagementService.saveFieldPermissions({
|
||||
updates: parsed.data.updates as never,
|
||||
user: authUser,
|
||||
})
|
||||
return { success: true as const, data }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false as const,
|
||||
message: error instanceof Error ? error.message : '字段权限保存失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||
import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service'
|
||||
import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, type StoreAccountFilters } from '@/lib/store-account-management'
|
||||
import { StoreAccountManagementView } from './StoreAccountManagementView'
|
||||
|
||||
@ -18,6 +19,9 @@ export default async function StoreAccountManagementPage({ searchParams }: { sea
|
||||
const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, 120)])) as StoreAccountFilters
|
||||
const page = Math.max(1, Number(first(params.page)) || 1)
|
||||
const pageSize = [50, 100, 200].includes(Number(first(params.pageSize))) ? Number(first(params.pageSize)) : 50
|
||||
const data = await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser })
|
||||
return <StoreAccountManagementView initialData={data} initialFilters={filters} canEdit={access.edit} canManagePermissions={access.edit && (authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN')} />
|
||||
const [data, capabilities] = await Promise.all([
|
||||
storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser }),
|
||||
storeAccountManagementAccessService.getCapabilities(authUser, access),
|
||||
])
|
||||
return <StoreAccountManagementView initialData={data} initialFilters={filters} canEdit={access.edit} canExport={capabilities.canExport} canManagePermissions={capabilities.canManageFieldPermissions} />
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ export type StoreAccountFieldPermissionValue = StoreAccountFieldVisibility | nul
|
||||
export type StoreAccountType = 'MAIN' | 'SUB' | '' | '/'
|
||||
export type StoreAccountBatchEditableFieldKey = Exclude<StoreAccountFieldKey, 'password' | 'createdAt' | 'updatedAt'>
|
||||
|
||||
export const STORE_ACCOUNT_EXPORT_SELECTION_LIMIT = 200
|
||||
|
||||
export const STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES = [
|
||||
{ key: 'design', label: '设计' },
|
||||
{ key: 'planning', label: '企划' },
|
||||
|
||||
@ -150,6 +150,14 @@ export class StoreAccountManagementRepository {
|
||||
return prisma.storeAccount.findMany({ where: { id: { in: ids } }, select: ACCOUNT_SELECT })
|
||||
}
|
||||
|
||||
async listForExport(ids?: string[]) {
|
||||
return prisma.storeAccount.findMany({
|
||||
where: ids ? { id: { in: ids } } : undefined,
|
||||
select: ACCOUNT_SELECT,
|
||||
orderBy: [{ storeName: 'asc' }, { account: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async upsertByKey(data: {
|
||||
storeName: string
|
||||
accountType: string
|
||||
@ -181,6 +189,17 @@ export class StoreAccountManagementRepository {
|
||||
return prisma.storeAccountFieldPermission.findMany({ select: { fieldKey: true, subjectKey: true, visibility: true } })
|
||||
}
|
||||
|
||||
async listPagePermissionOverrides() {
|
||||
const rows = await prisma.supplyChainPermissionOverride.findMany({
|
||||
where: { targetKey: 'storeAccountManagement' },
|
||||
select: { subjectKey: true, action: true, enabled: true },
|
||||
})
|
||||
return Object.fromEntries(rows.map((row) => [
|
||||
`storeAccountManagement:${row.subjectKey}:${row.action}`,
|
||||
row.enabled,
|
||||
]))
|
||||
}
|
||||
|
||||
async saveFieldPermissions(params: { updates: StoreAccountFieldPermissionUpdate[]; updatedById: string }) {
|
||||
return prisma.$transaction(params.updates.map((item) => item.visibility === null
|
||||
? prisma.storeAccountFieldPermission.deleteMany({
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { StoreAccountManagementAccessService } from '../store-account-management-access.service'
|
||||
|
||||
const managementUser = {
|
||||
id: 'management-1',
|
||||
email: 'management@example.com',
|
||||
name: '经管人员',
|
||||
role: 'USER' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
}
|
||||
|
||||
describe('StoreAccountManagementAccessService', () => {
|
||||
it('allows a management user with page view access to export and configure fields', async () => {
|
||||
const getUserAccess = jest.fn().mockResolvedValue({ view: true, download: false, edit: false })
|
||||
const service = new StoreAccountManagementAccessService({ getUserAccess })
|
||||
|
||||
await expect(service.getCapabilities(managementUser)).resolves.toEqual({
|
||||
pageAccess: { view: true, download: false, edit: false },
|
||||
canExport: true,
|
||||
canManageFieldPermissions: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects management users without page view access', async () => {
|
||||
const getUserAccess = jest.fn().mockResolvedValue({ view: false, download: false, edit: false })
|
||||
const service = new StoreAccountManagementAccessService({ getUserAccess })
|
||||
|
||||
await expect(service.assertCanExport(managementUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以导出店铺账号')
|
||||
await expect(service.assertCanManageFieldPermissions(managementUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
|
||||
})
|
||||
|
||||
it('rejects ordinary users even when they can view the page', async () => {
|
||||
const getUserAccess = jest.fn().mockResolvedValue({ view: true, download: true, edit: true })
|
||||
const service = new StoreAccountManagementAccessService({ getUserAccess })
|
||||
const ordinaryUser = {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '普通用户',
|
||||
role: 'USER' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
await expect(service.assertCanExport(ordinaryUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以导出店铺账号')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import { StoreAccountManagementExportService } from '../store-account-management-export.service'
|
||||
import type { StoreAccountManagementRepository } from '@/server/repositories/store-account-management.repository'
|
||||
import type { StoreAccountManagementAccessService } from '../store-account-management-access.service'
|
||||
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
|
||||
|
||||
jest.mock('@/lib/store-account-crypto', () => ({
|
||||
decryptStoreAccountPassword: (value: string) => value ? `明文:${value}` : '',
|
||||
}))
|
||||
|
||||
const adminUser = {
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
name: '管理员',
|
||||
role: 'ADMIN' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: 'account-1',
|
||||
storeName: '测试店铺',
|
||||
operationOwner: '负责人A',
|
||||
operationDepartmentHead: '部门负责人A',
|
||||
operationDirector: '总监A',
|
||||
profitCenter: '利润中心A',
|
||||
accountType: 'MAIN',
|
||||
account: 'shop-account',
|
||||
passwordCiphertext: 'ciphertext-1',
|
||||
boundPhone: '13800000000',
|
||||
verificationContact: '对接人A',
|
||||
jstOrderService: 'JST-001',
|
||||
accountGroupName: '账号组A',
|
||||
createdAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-02T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
describe('StoreAccountManagementExportService', () => {
|
||||
it('exports every column and writes the decrypted plaintext password', async () => {
|
||||
const listForExport = jest.fn().mockResolvedValue([record])
|
||||
const assertCanExport = jest.fn().mockResolvedValue(undefined)
|
||||
const service = new StoreAccountManagementExportService(
|
||||
{ listForExport } as unknown as StoreAccountManagementRepository,
|
||||
{ assertCanExport } as unknown as StoreAccountManagementAccessService,
|
||||
)
|
||||
|
||||
const payload = await service.createWorkbook({ user: adminUser })
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(payload.content)
|
||||
const worksheet = workbook.getWorksheet('店铺账号')!
|
||||
|
||||
expect(assertCanExport).toHaveBeenCalledWith(adminUser)
|
||||
expect(listForExport).toHaveBeenCalledWith(undefined)
|
||||
expect(worksheet.getRow(1).values).toEqual([
|
||||
undefined,
|
||||
'店铺名称',
|
||||
'运营负责人',
|
||||
'运营部门负责人',
|
||||
'运营总监',
|
||||
'利润中心',
|
||||
'账号类型',
|
||||
'账号',
|
||||
'密码',
|
||||
'绑定手机号',
|
||||
'手机号验证码对接人',
|
||||
'聚水潭订单服务号',
|
||||
'账号分组名称',
|
||||
'创建时间',
|
||||
'更新时间',
|
||||
])
|
||||
expect(worksheet.getCell('F2').value).toBe('主账号')
|
||||
expect(worksheet.getCell('H2').value).toBe('明文:ciphertext-1')
|
||||
expect(payload.count).toBe(1)
|
||||
expect(payload.fileName).toContain('店铺账号管理-全部-')
|
||||
})
|
||||
|
||||
it('exports only the selected identifiers and enforces the URL-safe selection limit', async () => {
|
||||
const listForExport = jest.fn().mockResolvedValue([record])
|
||||
const assertCanExport = jest.fn().mockResolvedValue(undefined)
|
||||
const service = new StoreAccountManagementExportService(
|
||||
{ listForExport } as unknown as StoreAccountManagementRepository,
|
||||
{ assertCanExport } as unknown as StoreAccountManagementAccessService,
|
||||
)
|
||||
|
||||
await service.createWorkbook({ ids: ['account-1', 'account-1'], user: adminUser })
|
||||
expect(listForExport).toHaveBeenCalledWith(['account-1'])
|
||||
|
||||
await expect(service.createWorkbook({
|
||||
ids: Array.from({ length: STORE_ACCOUNT_EXPORT_SELECTION_LIMIT + 1 }, (_, index) => `account-${index}`),
|
||||
user: adminUser,
|
||||
})).rejects.toThrow(`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号`)
|
||||
})
|
||||
})
|
||||
@ -70,14 +70,14 @@ describe('StoreAccountManagementService field permission settings', () => {
|
||||
subjectKey: 'operation',
|
||||
visibility: 'READONLY',
|
||||
}])
|
||||
jest.spyOn(supplyChainPermissionSettingsService, 'getSettings').mockResolvedValue({
|
||||
overrides: {
|
||||
'storeAccountManagement:operation:view': true,
|
||||
'storeAccountManagement:user@user-1:edit': true,
|
||||
'productArchive:operation:view': true,
|
||||
},
|
||||
const listPagePermissionOverrides = jest.fn().mockResolvedValue({
|
||||
'storeAccountManagement:operation:view': true,
|
||||
'storeAccountManagement:user@user-1:edit': true,
|
||||
})
|
||||
const service = new StoreAccountManagementService({ listFieldPermissions } as unknown as StoreAccountManagementRepository)
|
||||
const service = new StoreAccountManagementService({
|
||||
listFieldPermissions,
|
||||
listPagePermissionOverrides,
|
||||
} as unknown as StoreAccountManagementRepository)
|
||||
|
||||
const result = await service.getFieldPermissionSettings({
|
||||
id: 'admin-1',
|
||||
@ -94,7 +94,48 @@ describe('StoreAccountManagementService field permission settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow a non-admin to read or save field permission settings', async () => {
|
||||
it('allows management users with page access to read and save field permissions', async () => {
|
||||
jest.spyOn(supplyChainPermissionSettingsService, 'getUserAccess').mockResolvedValue({
|
||||
view: true,
|
||||
download: false,
|
||||
edit: false,
|
||||
})
|
||||
const listFieldPermissions = jest.fn().mockResolvedValue([])
|
||||
const listPagePermissionOverrides = jest.fn().mockResolvedValue({
|
||||
'storeAccountManagement:operation:view': true,
|
||||
})
|
||||
const saveFieldPermissions = jest.fn().mockResolvedValue([])
|
||||
const service = new StoreAccountManagementService({
|
||||
listFieldPermissions,
|
||||
listPagePermissionOverrides,
|
||||
saveFieldPermissions,
|
||||
} as unknown as StoreAccountManagementRepository)
|
||||
const user = {
|
||||
id: 'management-1',
|
||||
email: 'management@example.com',
|
||||
name: '经管人员',
|
||||
role: 'USER' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
}
|
||||
|
||||
await expect(service.getFieldPermissionSettings(user)).resolves.toEqual({
|
||||
fields: STORE_ACCOUNT_FIELDS,
|
||||
permissions: [],
|
||||
pagePermissionOverrides: { 'storeAccountManagement:operation:view': true },
|
||||
})
|
||||
await expect(service.saveFieldPermissions({
|
||||
updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }],
|
||||
user,
|
||||
})).resolves.toEqual({ saved: 1, inherited: 0 })
|
||||
})
|
||||
|
||||
it('does not allow an ordinary user to read or save field permission settings', async () => {
|
||||
jest.spyOn(supplyChainPermissionSettingsService, 'getUserAccess').mockResolvedValue({
|
||||
view: true,
|
||||
download: true,
|
||||
edit: true,
|
||||
})
|
||||
const listFieldPermissions = jest.fn()
|
||||
const saveFieldPermissions = jest.fn()
|
||||
const service = new StoreAccountManagementService({
|
||||
@ -109,11 +150,11 @@ describe('StoreAccountManagementService field permission settings', () => {
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有管理员可以查看字段权限配置')
|
||||
await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
|
||||
await expect(service.saveFieldPermissions({
|
||||
updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }],
|
||||
user,
|
||||
})).rejects.toThrow('只有管理员可以配置字段权限')
|
||||
})).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
|
||||
expect(listFieldPermissions).not.toHaveBeenCalled()
|
||||
expect(saveFieldPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
canManageSupplyChainPermissions,
|
||||
resolveSupplyChainPermissionSubjectKeys,
|
||||
} from '@/lib/supply-chain-permission-settings'
|
||||
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
|
||||
|
||||
type PageAccess = { view: boolean; download: boolean; edit: boolean }
|
||||
|
||||
type PermissionAccessReader = {
|
||||
getUserAccess: (user: AuthUser, targetKey: 'storeAccountManagement') => Promise<PageAccess>
|
||||
}
|
||||
|
||||
export function isStoreAccountSecurityManager(user: AuthUser) {
|
||||
return canManageSupplyChainPermissions(user)
|
||||
|| resolveSupplyChainPermissionSubjectKeys(user).includes('management')
|
||||
}
|
||||
|
||||
export class StoreAccountManagementAccessService {
|
||||
constructor(
|
||||
private readonly permissionAccessReader: PermissionAccessReader = supplyChainPermissionSettingsService,
|
||||
) {}
|
||||
|
||||
async getCapabilities(user: AuthUser, knownPageAccess?: PageAccess) {
|
||||
const pageAccess = knownPageAccess
|
||||
?? await this.permissionAccessReader.getUserAccess(user, 'storeAccountManagement')
|
||||
const canManageSensitiveActions = pageAccess.view && isStoreAccountSecurityManager(user)
|
||||
return {
|
||||
pageAccess,
|
||||
canExport: canManageSensitiveActions,
|
||||
canManageFieldPermissions: canManageSensitiveActions,
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanExport(user: AuthUser) {
|
||||
const capabilities = await this.getCapabilities(user)
|
||||
if (!capabilities.canExport) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有拥有页面权限的管理员或经管可以导出店铺账号')
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
async assertCanManageFieldPermissions(user: AuthUser) {
|
||||
const capabilities = await this.getCapabilities(user)
|
||||
if (!capabilities.canManageFieldPermissions) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有拥有页面权限的管理员或经管可以配置字段权限')
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
}
|
||||
|
||||
export const storeAccountManagementAccessService = new StoreAccountManagementAccessService()
|
||||
@ -0,0 +1,127 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
|
||||
import {
|
||||
storeAccountManagementRepository,
|
||||
type StoreAccountManagementRepository,
|
||||
} from '@/server/repositories/store-account-management.repository'
|
||||
import {
|
||||
storeAccountManagementAccessService,
|
||||
type StoreAccountManagementAccessService,
|
||||
} from './store-account-management-access.service'
|
||||
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'storeName', header: '店铺名称', width: 24 },
|
||||
{ key: 'operationOwner', header: '运营负责人', width: 16 },
|
||||
{ key: 'operationDepartmentHead', header: '运营部门负责人', width: 18 },
|
||||
{ key: 'operationDirector', header: '运营总监', width: 16 },
|
||||
{ key: 'profitCenter', header: '利润中心', width: 16 },
|
||||
{ key: 'accountType', header: '账号类型', width: 12 },
|
||||
{ key: 'account', header: '账号', width: 24 },
|
||||
{ key: 'password', header: '密码', width: 24 },
|
||||
{ key: 'boundPhone', header: '绑定手机号', width: 18 },
|
||||
{ key: 'verificationContact', header: '手机号验证码对接人', width: 22 },
|
||||
{ key: 'jstOrderService', header: '聚水潭订单服务号', width: 22 },
|
||||
{ key: 'accountGroupName', header: '账号分组名称', width: 18 },
|
||||
{ key: 'createdAt', header: '创建时间', width: 20 },
|
||||
{ key: 'updatedAt', header: '更新时间', width: 20 },
|
||||
] as const
|
||||
|
||||
function formatAccountType(value: string) {
|
||||
if (value === 'MAIN') return '主账号'
|
||||
if (value === 'SUB') return '子账号'
|
||||
return value
|
||||
}
|
||||
|
||||
function fileStamp() {
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date())
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''
|
||||
return `${value('year')}${value('month')}${value('day')}-${value('hour')}${value('minute')}${value('second')}`
|
||||
}
|
||||
|
||||
export class StoreAccountManagementExportService {
|
||||
constructor(
|
||||
private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository,
|
||||
private readonly accessService: StoreAccountManagementAccessService = storeAccountManagementAccessService,
|
||||
) {}
|
||||
|
||||
async createWorkbook(input: { ids?: string[]; user: AuthUser }) {
|
||||
await this.accessService.assertCanExport(input.user)
|
||||
const selectedIds = input.ids
|
||||
? [...new Set(input.ids.map((id) => id.trim()).filter(Boolean))]
|
||||
: undefined
|
||||
if (selectedIds && selectedIds.length === 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要导出的店铺账号')
|
||||
}
|
||||
if (selectedIds && selectedIds.length > STORE_ACCOUNT_EXPORT_SELECTION_LIMIT) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号,可分批导出或使用导出全部`,
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await this.repository.listForExport(selectedIds)
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
workbook.creator = input.user.name
|
||||
workbook.created = new Date()
|
||||
const worksheet = workbook.addWorksheet('店铺账号')
|
||||
worksheet.columns = EXPORT_COLUMNS.map((column) => ({
|
||||
key: column.key,
|
||||
header: column.header,
|
||||
width: column.width,
|
||||
}))
|
||||
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
|
||||
worksheet.autoFilter = { from: 'A1', to: 'N1' }
|
||||
|
||||
rows.forEach((row) => {
|
||||
worksheet.addRow({
|
||||
storeName: row.storeName,
|
||||
operationOwner: row.operationOwner,
|
||||
operationDepartmentHead: row.operationDepartmentHead,
|
||||
operationDirector: row.operationDirector,
|
||||
profitCenter: row.profitCenter,
|
||||
accountType: formatAccountType(row.accountType),
|
||||
account: row.account,
|
||||
password: decryptStoreAccountPassword(row.passwordCiphertext),
|
||||
boundPhone: row.boundPhone,
|
||||
verificationContact: row.verificationContact,
|
||||
jstOrderService: row.jstOrderService,
|
||||
accountGroupName: row.accountGroupName,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
const header = worksheet.getRow(1)
|
||||
header.height = 24
|
||||
header.font = { bold: true, color: { argb: 'FFFFFFFF' } }
|
||||
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } }
|
||||
header.alignment = { vertical: 'middle', horizontal: 'center' }
|
||||
worksheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd hh:mm:ss'
|
||||
worksheet.getColumn('updatedAt').numFmt = 'yyyy-mm-dd hh:mm:ss'
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) row.alignment = { vertical: 'middle' }
|
||||
})
|
||||
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer())
|
||||
const scope = selectedIds ? `已选${rows.length}条` : '全部'
|
||||
return {
|
||||
content,
|
||||
fileName: `店铺账号管理-${scope}-${fileStamp()}.xlsx`,
|
||||
count: rows.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const storeAccountManagementExportService = new StoreAccountManagementExportService()
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import { decryptStoreAccountPassword, encryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
import { canManageSupplyChainPermissions, resolveSupplyChainPermissionSubjectKeys } from '@/lib/supply-chain-permission-settings'
|
||||
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
|
||||
import { storeAccountManagementAccessService } from './store-account-management-access.service'
|
||||
import {
|
||||
storeAccountManagementRepository,
|
||||
type StoreAccountManagementRepository,
|
||||
@ -244,7 +245,7 @@ export class StoreAccountManagementService {
|
||||
}
|
||||
|
||||
async saveFieldPermissions(input: { updates: StoreAccountFieldPermissionUpdate[]; user: AuthUser }) {
|
||||
if (!canManageSupplyChainPermissions(input.user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以配置字段权限')
|
||||
await storeAccountManagementAccessService.assertCanManageFieldPermissions(input.user)
|
||||
const updates = Array.from(new Map(input.updates.map((item) => [`${item.fieldKey}:${item.subjectKey}`, item])).values())
|
||||
const invalid = updates.some((item) => (
|
||||
!STORE_ACCOUNT_FIELDS.some((field) => field.key === item.fieldKey && !field.system)
|
||||
@ -258,13 +259,13 @@ export class StoreAccountManagementService {
|
||||
}
|
||||
|
||||
async getFieldPermissionSettings(user: AuthUser) {
|
||||
if (!canManageSupplyChainPermissions(user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以查看字段权限配置')
|
||||
await storeAccountManagementAccessService.assertCanManageFieldPermissions(user)
|
||||
const [permissions, pagePermissionSettings] = await Promise.all([
|
||||
this.repository.listFieldPermissions(),
|
||||
supplyChainPermissionSettingsService.getSettings(user),
|
||||
this.repository.listPagePermissionOverrides(),
|
||||
])
|
||||
const pagePermissionOverrides = Object.fromEntries(
|
||||
Object.entries(pagePermissionSettings.overrides).filter(([key]) => (
|
||||
Object.entries(pagePermissionSettings).filter(([key]) => (
|
||||
key.startsWith(STORE_ACCOUNT_PAGE_PERMISSION_PREFIX)
|
||||
)),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user