feat: add Dieya product AI center portal

This commit is contained in:
kx_guo 2026-10-23 14:36:16 +08:00
commit 8d36ac96ee
12 changed files with 447 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
.DS_Store
*.log

77
README.md Normal file
View File

@ -0,0 +1,77 @@
# 碟雅产品AI中心
独立的统一应用门户项目,已配置提供的 8 个业务地址。采用原生 HTML、CSS、JavaScript 模块,无第三方依赖、无外部字体或 CDN;不需要 npm install。可独立部署到 Nginx、静态网站托管或现有 Web 服务器。
## 启动与构建
需要 Node.js 18 或更高版本。在项目目录打开终端:
```sh
npm run dev
```
浏览器访问 http://127.0.0.1:5173 。指定端口:`npm run dev -- --port 8080`。开发预览服务默认仅监听本机;生产环境请部署构建出的静态文件。
```sh
npm test
npm run build
```
将 `dist/` **里面的文件**上传到网站目录即可。无需运行 Node.js 生产服务。文件使用相对路径,支持部署在域名根目录或带结尾 `/` 的子目录。必须通过 HTTP/HTTPS 访问,不支持直接双击 HTML。服务器需为 `.mjs` 配置 JavaScript MIME 类型,参考 `deploy/nginx.conf.example`。
## 已集成入口
| 应用 | 地址 | 环境 |
| --- | --- | --- |
| 碟雅运营AI中心 | https://aiagent.dieya.net.cn/ | 正式 |
| 碟雅AI中心测试版 | https://aiagent1.dieya.net.cn/ | 测试 |
| 需求收集 | http://demand.dieya.net.cn/ | 正式 |
| 财务AI助手测试 | http://finance-test.dieya.net.cn/ | 测试 |
| 成本识别 | http://cost.dieya.net.cn/ | 正式 |
| 违禁词助手 | http://prohibite.dieya.net.cn/ | 正式 |
| 风险预警测试 | http://risk.dieya.net.cn/ | 测试 |
| 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 |
不包含制度小助手。仅加入本次给出地址的 8 个应用,没有猜测截图中其他项目的地址。
## 页面功能
- 应用卡片点击后在当前窗口进入配置的完整 URL,浏览器或钉钉容器的返回操作可回到门户。
- 按应用名称、简介或域名搜索;按分类、正式/测试环境筛选。
- 星标收藏与“我的常用”,保存在当前浏览器的 localStorage,不跨设备同步,也不作为权限依据。
- 自适应桌面和手机,支持键盘访问;按 `/` 聚焦搜索,按 Escape 清空搜索。
## 维护应用
修改 `src/apps.mjs` 中的 `applications`,运行测试和构建后重新发布。
```js
{
id: 'unique-id',
name: '应用名称',
shortName: '简称',
description: '应用说明',
url: 'http://your-app.example.com/',
category: 'tools', // ai / tools / risk
environment: 'production', // production / test
color: 'blue', // blue / violet / teal / amber
icon: 'clipboard' // sparkles / clipboard / wallet / scan / text / shield
}
```
## 接入钉钉
1. 确定新门户的正式部署地址。此项目尚未绑定域名或发布到服务器。
2. 在钉钉开发者后台创建或配置企业内部网页应用,把门户的 URL 配为对应的移动端和电脑端首页,并配置需要的应用可见范围。具体后台字段以企业应用类型为准。
3. 从钉钉手机端与电脑端打开门户,逐个验证进入目标应用及返回门户的操作。
4. 目标系统各自维护免登、可信域名、回调地址和服务端权限校验。本门户没有模拟登录、没有采集凭据,也不会把 token 或免登码拼到目标链接。
5. 特别确认原应用是否依赖从工作台传入的 corpId、应用上下文或特定入口路径。普通域名跳转不会自动恢复这些参数;若依赖,需按目标应用的实际认证方式修改完整入口或做应用启动适配。
6. 验证全部入口可用后,再由企业管理员决定原工作台入口如何调整。制度小助手保留原独立入口。
运营 AI 中心和 AI 中心测试版已按确认的访问地址使用 HTTPS,其余 6 个应用保留原提供的 HTTP 地址。正式发布时请使用实际可用的协议;如果其他目标系统启用 HTTPS,应在配置中更新并重新验证。
## 集成范围
本项目完成的是应用入口统一,未合并原业务代码、数据库、登录会话或数据权限。所有卡片对能访问门户的人可见;本项目不是访问控制系统。如果后续需要按部门/角色展示卡片,需要增加门户后端或接入现有身份服务,且目标系统仍应自行鉴权。
已验证静态构建、入口映射与筛选逻辑;目标系统的线上可用性和钉钉内免登需在企业实际环境联调。

21
deploy/nginx.conf.example Normal file
View File

@ -0,0 +1,21 @@
# 把 dist/ 的内容上传到此目录,再按实际域名、路径与 HTTPS 证书修改配置。
server {
listen 80;
server_name portal.example.com;
root /var/www/dieya-product-ai-center;
index index.html;
location / {
try_files $uri $uri/ =404;
}
# .mjs 必须使用 JavaScript MIME 类型。
location ~ \.mjs$ {
types { application/javascript mjs; }
add_header Cache-Control "no-cache";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}

1
favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="17" fill="#215ae8"/><text x="32" y="43" text-anchor="middle" font-size="36" font-family="Microsoft YaHei,sans-serif" font-weight="600" fill="#fff">碟</text></svg>

After

Width:  |  Height:  |  Size: 257 B

52
index.html Normal file
View File

@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#215ae8">
<meta name="description" content="碟雅产品AI中心,统一访问业务工具与 AI 应用。">
<title>碟雅产品AI中心</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./src/styles.css">
</head>
<body>
<a class="skip-link" href="#main">跳转到应用列表</a>
<aside class="sidebar">
<a class="brand" href="./" aria-label="碟雅产品AI中心首页">
<span class="brand-mark" aria-hidden="true">碟</span>
<span><strong>碟雅</strong><small>产品AI中心</small></span>
</a>
<div class="workspace-label">WORKSPACE <span>工作空间</span></div>
<nav id="category-nav" aria-label="应用分类"></nav>
<div class="sidebar-bottom"><span class="small-mark" aria-hidden="true">D</span><div><strong>碟雅产品AI中心</strong><small>让工作,从这里开始</small></div></div>
</aside>
<div class="page">
<header class="topbar">
<div class="breadcrumb">工作台 <span>/</span> <strong>产品AI中心</strong></div>
<div class="topbar-right"><span class="portal-indicator">统一应用门户</span><span class="avatar" aria-hidden="true">碟</span></div>
</header>
<main id="main" tabindex="-1">
<section class="welcome" aria-labelledby="welcome-title">
<div class="welcome-copy"><div class="eyebrow"><span></span> DIEYA WORKSPACE</div>
<h1 id="welcome-title">每一份效率,都有入口。</h1>
<p>连接 AI 与业务,让常用应用触手可及。</p>
<div class="hero-meta"><span><strong id="total-count">8</strong> 个应用</span><i></i><span>一个入口,开启工作</span></div>
</div>
<div class="hero-art" aria-hidden="true"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="art-tile tile-one">AI<span>✦</span></div><div class="art-tile tile-two">D</div><div class="art-tile tile-three">↗</div><span class="art-dot dot-one"></span><span class="art-dot dot-two"></span></div>
</section>
<section class="application-section" aria-labelledby="section-title">
<div class="section-heading"><div><h2 id="section-title">全部应用</h2><p id="section-description">你的业务工具与 AI 助手,都在这里。</p></div>
<label class="search-box"><span id="search-icon" aria-hidden="true"></span><input id="search" type="search" placeholder="搜索应用名称或域名" aria-label="搜索应用名称或域名" autocomplete="off"><kbd aria-hidden="true">/</kbd></label>
</div>
<div class="filter-row"><div id="environment-filters" class="environment-filters" role="group" aria-label="筛选应用环境"></div><span id="result-count" class="result-count" role="status" aria-live="polite"></span></div>
<div id="app-grid" class="app-grid"></div>
<div id="empty-state" class="empty-state" hidden><div id="empty-icon" aria-hidden="true"></div><h3 id="empty-title">没有找到匹配的应用</h3><p id="empty-description">试试其他关键词,或调整筛选条件。</p><button id="reset-filters" class="reset-button" type="button">重置筛选</button></div>
</section>
<footer class="footer"><span>碟雅产品AI中心</span><span>连接业务 · 提升效率</span></footer>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<noscript><div class="noscript">请启用 JavaScript 以显示应用入口。</div></noscript>
<script type="module" src="./src/main.mjs"></script>
</body>
</html>

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "dieya-product-ai-center",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "碟雅产品AI中心",
"engines": { "node": ">=18" },
"scripts": {
"dev": "node scripts/serve.mjs",
"start": "node scripts/serve.mjs",
"build": "node scripts/build.mjs",
"test": "node --test tests/*.test.mjs"
}
}

13
scripts/build.mjs Normal file
View File

@ -0,0 +1,13 @@
import { cp, mkdir, rm } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { applications, validateApplications } from '../src/apps.mjs';
validateApplications(applications);
const root = new URL('../', import.meta.url);
const output = new URL('dist/', root);
await rm(output, { recursive: true, force: true });
await mkdir(output, { recursive: true });
for (const name of ['index.html', 'favicon.svg', 'src']) {
await cp(new URL(name, root), new URL(name, output), { recursive: true });
}
console.log(`构建成功:${fileURLToPath(output)}(${applications.length} 个应用)`);

26
scripts/serve.mjs Normal file
View File

@ -0,0 +1,26 @@
import http from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
const arg = (name, fallback) => { const index = args.indexOf(name); return index < 0 ? fallback : args[index + 1]; };
const port = Number(arg('--port', process.env.PORT || '5173'));
const host = arg('--host', '127.0.0.1');
const root = path.resolve(fileURLToPath(new URL('../', import.meta.url)));
const publicFiles = new Set(['index.html', 'favicon.svg', 'src/styles.css', 'src/apps.mjs', 'src/main.mjs']);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; }
try {
const url = new URL(request.url, 'http://localhost');
const relative = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html';
if (!publicFiles.has(relative)) { response.writeHead(404); response.end('Not found'); return; }
const file = path.resolve(root, relative);
if (!(await stat(file)).isFile()) throw new Error('Not a file');
response.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
response.end(request.method === 'HEAD' ? undefined : await readFile(file));
} catch { response.writeHead(404); response.end('Not found'); }
});
server.on('error', error => { console.error(`启动失败:${error.message}`); process.exitCode = 1; });
server.listen(port, host, () => console.log(`碟雅产品AI中心:http://${host}:${port}`));

83
src/apps.mjs Normal file
View File

@ -0,0 +1,83 @@
// 新增或修改应用只需维护此配置。各入口使用用户确认的域名与协议。
export const applications = [
{
id: 'operations-ai', name: '碟雅运营AI中心', shortName: '运营',
description: '运营 AI 应用统一入口',
url: 'https://aiagent.dieya.net.cn/',
category: 'ai', environment: 'production', color: 'blue', icon: 'sparkles'
},
{
id: 'ai-center-test', name: '碟雅AI中心测试版', shortName: 'AI',
description: 'AI 中心测试应用入口',
url: 'https://aiagent1.dieya.net.cn/',
category: 'ai', environment: 'test', color: 'violet', icon: 'sparkles'
},
{
id: 'demand', name: '需求收集', shortName: '需求',
description: '业务需求收集与协作入口',
url: 'http://demand.dieya.net.cn/',
category: 'tools', environment: 'production', color: 'teal', icon: 'clipboard'
},
{
id: 'finance-test', name: '财务AI助手测试', shortName: '财务',
description: '财务 AI 助手测试应用入口',
url: 'http://finance-test.dieya.net.cn/',
category: 'ai', environment: 'test', color: 'amber', icon: 'wallet'
},
{
id: 'cost', name: '成本识别', shortName: '成本',
description: '成本识别业务应用入口',
url: 'http://cost.dieya.net.cn/',
category: 'tools', environment: 'production', color: 'blue', icon: 'scan'
},
{
id: 'prohibite', name: '违禁词助手', shortName: '文本',
description: '违禁词检查应用入口',
url: 'http://prohibite.dieya.net.cn/',
category: 'tools', environment: 'production', color: 'violet', icon: 'text'
},
{
id: 'risk-test', name: '风险预警测试', shortName: '风控',
description: '风险预警测试应用入口',
url: 'http://risk.dieya.net.cn/',
category: 'risk', environment: 'test', color: 'amber', icon: 'shield'
},
{
id: 'risk', name: '风险预警', shortName: '风控',
description: '风险预警正式应用入口',
url: 'http://risk-prod.dieya.net.cn/',
category: 'risk', environment: 'production', color: 'teal', icon: 'shield'
}
];
export const categories = [
{ id: 'all', name: '全部应用', icon: 'grid' },
{ id: 'ai', name: 'AI 助手', icon: 'sparkles' },
{ id: 'tools', name: '业务工具', icon: 'clipboard' },
{ id: 'risk', name: '风险管理', icon: 'shield' },
{ id: 'favorites', name: '我的常用', icon: 'star' }
];
export function filterApplications(apps, { query = '', category = 'all', environment = 'all', favorites = [] } = {}) {
const term = query.trim().toLocaleLowerCase();
return apps.filter(app =>
(category === 'all' || (category === 'favorites' ? favorites.includes(app.id) : app.category === category)) &&
(environment === 'all' || app.environment === environment) &&
(!term || [app.name, app.description, app.url].some(value => value.toLocaleLowerCase().includes(term)))
);
}
export function validateApplications(apps) {
const ids = new Set();
for (const app of apps) {
if (!app.id || ids.has(app.id)) throw new Error(`应用 ID 缺失或重复:${app.id}`);
ids.add(app.id);
const url = new URL(app.url);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error(`应用地址无效:${app.name}`);
}
if (!app.name || !app.description || !['ai', 'tools', 'risk'].includes(app.category) ||
!['production', 'test'].includes(app.environment)) throw new Error(`应用配置不完整:${app.id}`);
}
return true;
}

110
src/main.mjs Normal file
View File

@ -0,0 +1,110 @@
import { applications, categories, filterApplications, validateApplications } from './apps.mjs';
validateApplications(applications);
const icons = {
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
sparkles: '<path d="m12 3 2.6 6.4L21 12l-6.4 2.6L12 21l-2.6-6.4L3 12l6.4-2.6L12 3Z"/><path d="m20 2 .6 1.4L22 4l-1.4.6L20 6l-.6-1.4L18 4l1.4-.6L20 2Z"/>',
clipboard: '<rect x="5" y="5" width="14" height="16" rx="2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/>',
wallet: '<path d="M20 8H5a2 2 0 0 1 0-4h12v4M3 6v12a2 2 0 0 0 2 2h15V8"/><path d="M20 12h-5v4h5M16 14h.01"/>',
scan: '<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m8 0h3a2 2 0 0 0 2-2v-3M3 12h18M8 8h8M8 16h5"/>',
text: '<path d="M4 5h13M10.5 5v14M7 19h7M3 9V5m15 4V5m-1 11 2 2 3-4"/>',
shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"/><path d="M12 8v5m0 3h.01"/>',
star: '<path d="m12 3 2.8 5.7 6.3.9-4.6 4.4 1.1 6.3-5.6-3-5.6 3 1.1-6.3L3 9.6l6.2-.9L12 3Z"/>',
arrow: '<path d="M5 12h14m-5-5 5 5-5 5"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/>'
};
const svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.grid}</svg>`;
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const storageKey = 'dieya-portal:favorites:v1';
function readFavorites() {
try {
const stored = JSON.parse(localStorage.getItem(storageKey) || '[]');
return Array.isArray(stored) ? stored.filter(id => applications.some(app => app.id === id)) : [];
} catch { return []; }
}
const state = { query: '', category: 'all', environment: 'all', favorites: readFavorites() };
const grid = document.querySelector('#app-grid');
const search = document.querySelector('#search');
const nav = document.querySelector('#category-nav');
const filters = document.querySelector('#environment-filters');
document.querySelector('#search-icon').innerHTML = svg('search');
document.querySelector('#empty-icon').innerHTML = svg('search');
document.querySelector('#total-count').textContent = applications.length;
const environmentLabels = [{ id: 'all', label: '全部' }, { id: 'production', label: '正式应用' }, { id: 'test', label: '测试应用' }];
let toastTimer;
function renderNav() {
nav.innerHTML = categories.map(category => {
const count = category.id === 'favorites' ? state.favorites.length : category.id === 'all' ? applications.length : applications.filter(app => app.category === category.id).length;
return `<button type="button" class="nav-item${state.category === category.id ? ' active' : ''}" data-category="${category.id}" aria-pressed="${state.category === category.id}">${svg(category.icon)}<span>${category.name}</span><small>${count}</small></button>`;
}).join('');
}
function render() {
renderNav();
filters.innerHTML = environmentLabels.map(item => `<button type="button" class="filter-button${state.environment === item.id ? ' active' : ''}" data-environment="${item.id}" aria-pressed="${state.environment === item.id}">${item.label}</button>`).join('');
document.querySelector('#section-title').textContent = categories.find(category => category.id === state.category).name;
document.querySelector('#section-description').textContent = state.category === 'favorites' ? '收藏你常用的应用,下次更快找到。' : '你的业务工具与 AI 助手,都在这里。';
const matches = filterApplications(applications, state);
document.querySelector('#result-count').textContent = `共 ${matches.length} 个应用`;
grid.innerHTML = matches.map(app => {
const favorite = state.favorites.includes(app.id);
const categoryName = categories.find(category => category.id === app.category).name;
return `<article class="app-card">
<div class="card-top"><span class="app-icon ${escapeHtml(app.color)}">${svg(app.icon)}</span><button type="button" class="favorite-button${favorite ? ' selected' : ''}" data-favorite="${escapeHtml(app.id)}" aria-label="${favorite ? '取消常用:' : '设为常用:'}${escapeHtml(app.name)}" aria-pressed="${favorite}" title="${favorite ? '取消常用' : '设为常用'}">${svg('star')}</button></div>
<h3><a class="app-link" href="${escapeHtml(app.url)}" aria-label="打开${escapeHtml(app.name)}">${escapeHtml(app.name)}</a></h3>
<p class="app-description">${escapeHtml(app.description)}</p>
<div class="card-tags"><span class="category-tag">${escapeHtml(categoryName)}</span>${app.environment === 'test' ? '<span class="test-tag">测试</span>' : '<span class="production-tag"><i></i>正式</span>'}</div>
<div class="card-bottom"><span class="app-domain" title="${escapeHtml(app.url)}">${escapeHtml(new URL(app.url).hostname)}</span><span class="launch-icon" aria-hidden="true">${svg('arrow')}</span></div>
</article>`;
}).join('');
document.querySelector('#empty-state').hidden = matches.length > 0;
const emptyFavorites = state.category === 'favorites' && state.favorites.length === 0;
document.querySelector('#empty-title').textContent = emptyFavorites ? '把常用应用放在这里' : '没有找到匹配的应用';
document.querySelector('#empty-description').textContent = emptyFavorites ? '点击应用卡片右上角的星标,即可加入我的常用。' : '试试其他关键词,或调整筛选条件。';
}
nav.addEventListener('click', event => {
const button = event.target.closest('[data-category]');
if (!button) return;
state.category = button.dataset.category;
render();
});
filters.addEventListener('click', event => {
const button = event.target.closest('[data-environment]');
if (!button) return;
state.environment = button.dataset.environment;
render();
});
search.addEventListener('input', () => { state.query = search.value; render(); });
grid.addEventListener('click', event => {
const button = event.target.closest('[data-favorite]');
if (!button) return;
const id = button.dataset.favorite;
const adding = !state.favorites.includes(id);
state.favorites = adding ? [...state.favorites, id] : state.favorites.filter(value => value !== id);
let persisted = true;
try { localStorage.setItem(storageKey, JSON.stringify(state.favorites)); } catch { persisted = false; }
render();
grid.querySelector(`[data-favorite="${id}"]`)?.focus({ preventScroll: true });
const toast = document.querySelector('#toast');
toast.textContent = `${adding ? '已加入我的常用' : '已取消常用'}${persisted ? '' : '(当前浏览器无法保存,仅本次有效)'}`;
toast.classList.add('visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2400);
});
document.querySelector('#reset-filters').addEventListener('click', () => {
state.category = 'all'; state.environment = 'all'; state.query = ''; search.value = ''; render();
});
document.addEventListener('keydown', event => {
if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.target.closest('input,textarea,[contenteditable="true"]')) {
event.preventDefault(); search.focus();
}
if (event.key === 'Escape' && document.activeElement === search) {
search.value = ''; state.query = ''; render(); search.blur();
}
});
window.addEventListener('storage', event => {
if (event.key === storageKey || event.key === null) { state.favorites = readFavorites(); render(); }
});
render();

9
src/styles.css Normal file

File diff suppressed because one or more lines are too long

37
tests/apps.test.mjs Normal file
View File

@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applications, filterApplications, validateApplications } from '../src/apps.mjs';
test('八个业务地址与用户给定映射一致,正式和测试风险入口不混淆', () => {
const expected = {
'碟雅运营AI中心': 'https://aiagent.dieya.net.cn/',
'碟雅AI中心测试版': 'https://aiagent1.dieya.net.cn/',
'需求收集': 'http://demand.dieya.net.cn/',
'财务AI助手测试': 'http://finance-test.dieya.net.cn/',
'成本识别': 'http://cost.dieya.net.cn/',
'违禁词助手': 'http://prohibite.dieya.net.cn/',
'风险预警测试': 'http://risk.dieya.net.cn/',
'风险预警': 'http://risk-prod.dieya.net.cn/'
};
assert.deepEqual(Object.fromEntries(applications.map(app => [app.name, app.url])), expected);
assert.equal(validateApplications(applications), true);
assert.equal(filterApplications(applications, { environment: 'production' }).length, 5);
assert.equal(filterApplications(applications, { environment: 'test' }).length, 3);
});
test('分类、中文或域名搜索和环境条件同时生效', () => {
assert.deepEqual(filterApplications(applications, { category: 'risk', environment: 'production' }).map(app => app.id), ['risk']);
assert.equal(filterApplications(applications, { query: ' 风险预警 ' }).length, 2);
assert.deepEqual(filterApplications(applications, { query: 'AIAGENT1.DIEYA' }).map(app => app.id), ['ai-center-test']);
assert.equal(filterApplications(applications, { query: '不存在的应用' }).length, 0);
});
test('常用列表与环境和关键词组合筛选', () => {
const options = { category: 'favorites', favorites: ['cost', 'finance-test'] };
assert.equal(filterApplications(applications, options).length, 2);
assert.deepEqual(filterApplications(applications, { ...options, environment: 'test', query: '财务' }).map(app => app.id), ['finance-test']);
assert.equal(filterApplications(applications, { category: 'favorites', favorites: [] }).length, 0);
});
test('拒绝非网页链接、重复标识和不完整配置', () => {
assert.throws(() => validateApplications([{ ...applications[0], url: 'javascript:alert(1)' }]));
assert.throws(() => validateApplications([applications[0], applications[0]]));
assert.throws(() => validateApplications([{ ...applications[0], environment: 'unknown' }]));
});