feat: redesign product AI center portal

This commit is contained in:
kx_guo 2026-10-26 15:47:56 +08:00
parent 8d36ac96ee
commit 4403f6bb1d
8 changed files with 130 additions and 278 deletions

View File

@ -1,77 +1,60 @@
# 碟雅产品AI中心 # 蝶雅产品AI中心
独立的统一应用门户项目,已配置提供的 8 个业务地址。采用原生 HTML、CSS、JavaScript 模块,无第三方依赖、无外部字体或 CDN;不需要 npm install。可独立部署到 Nginx、静态网站托管或现有 Web 服务器。 面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目采用原生 HTML、CSS、JavaScript 模块,无第三方依赖,可直接部署到宝塔 Nginx 静态网站。
## 启动与构建
需要 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/ | 正式 | | 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 |
| 成本识别 | http://cost.dieya.net.cn/ | 正式 |
不包含制度小助手。仅加入本次给出地址的 8 个应用,没有猜测截图中其他项目的地址。 其他业务入口、测试版应用和制度小助手不在本门户展示。
## 页面功能 ## 本地启动
- 应用卡片点击后在当前窗口进入配置的完整 URL,浏览器或钉钉容器的返回操作可回到门户。 需要 Node.js 18 或更高版本。项目没有第三方依赖,不需要 `npm install`:
- 按应用名称、简介或域名搜索;按分类、正式/测试环境筛选。
- 星标收藏与“我的常用”,保存在当前浏览器的 localStorage,不跨设备同步,也不作为权限依据。
- 自适应桌面和手机,支持键盘访问;按 `/` 聚焦搜索,按 Escape 清空搜索。
## 维护应用 ```sh
npm test
npm run dev
```
修改 `src/apps.mjs` 中的 `applications`,运行测试和构建后重新发布。 默认访问 http://127.0.0.1:5173,也可以指定端口:`npm run dev -- --port 8080`。
```js ## 宝塔部署
{
id: 'unique-id', 生产环境只部署 `dist/` 目录中的静态文件:
name: '应用名称',
shortName: '简称', ```sh
description: '应用说明', npm run build
url: 'http://your-app.example.com/', ```
category: 'tools', // ai / tools / risk
environment: 'production', // production / test 将 `dist/` 里面的文件上传到网站目录,或在宝塔网站设置中将网站根目录指向:
color: 'blue', // blue / violet / teal / amber
icon: 'clipboard' // sparkles / clipboard / wallet / scan / text / shield ```text
/www/wwwroot/dieya-product-ai-center/dist
```
不需要创建 Node 项目,不需要 PHP,不需要反向代理。网站默认文档使用 `index.html`。服务器需为 `.mjs` 文件返回 JavaScript 类型,Nginx 可在站点配置中加入:
```nginx
location ~ \.mjs$ {
types { application/javascript mjs; }
add_header Cache-Control "no-cache";
try_files $uri =404;
} }
``` ```
## 接入钉钉 ## 修改入口
1. 确定新门户的正式部署地址。此项目尚未绑定域名或发布到服务器。 编辑 `src/apps.mjs` 中的 `applications` 数组,然后重新构建并部署 `dist`。当前配置会校验应用必须是产品部正式入口,不允许测试环境配置混入对外门户。
2. 在钉钉开发者后台创建或配置企业内部网页应用,把门户的 URL 配为对应的移动端和电脑端首页,并配置需要的应用可见范围。具体后台字段以企业应用类型为准。
3. 从钉钉手机端与电脑端打开门户,逐个验证进入目标应用及返回门户的操作。
4. 目标系统各自维护免登、可信域名、回调地址和服务端权限校验。本门户没有模拟登录、没有采集凭据,也不会把 token 或免登码拼到目标链接。
5. 特别确认原应用是否依赖从工作台传入的 corpId、应用上下文或特定入口路径。普通域名跳转不会自动恢复这些参数;若依赖,需按目标应用的实际认证方式修改完整入口或做应用启动适配。
6. 验证全部入口可用后,再由企业管理员决定原工作台入口如何调整。制度小助手保留原独立入口。
运营 AI 中心和 AI 中心测试版已按确认的访问地址使用 HTTPS,其余 6 个应用保留原提供的 HTTP 地址。正式发布时请使用实际可用的协议;如果其他目标系统启用 HTTPS,应在配置中更新并重新验证。 ## 页面功能
## 集成范围 - 参考多智能体工作台的简洁卡片风格,支持浅色和深色主题按钮切换。
- 顶部品牌导航、产品部工具卡片和响应式布局,避免复杂的筛选与侧栏层级。
- 每张卡片展示产品部标签、正式应用状态和打开工作台按钮。
- 主题选择保存在当前浏览器中,不影响应用权限。
本项目完成的是应用入口统一,未合并原业务代码、数据库、登录会话或数据权限。所有卡片对能访问门户的人可见;本项目不是访问控制系统。如果后续需要按部门/角色展示卡片,需要增加门户后端或接入现有身份服务,且目标系统仍应自行鉴权。 目标业务系统仍需自行负责登录、钉钉免登、可信域名和权限校验。本门户只负责统一展示和跳转。
已验证静态构建、入口映射与筛选逻辑;目标系统的线上可用性和钉钉内免登需在企业实际环境联调。

View File

@ -1,52 +1,42 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#215ae8"> <meta name="theme-color" content="#f5f8fc">
<meta name="description" content="碟雅产品AI中心,统一访问业务工具与 AI 应用。"> <meta name="description" content="蝶雅产品AI中心,产品部智能工作台。">
<title>碟雅产品AI中心</title> <title>蝶雅产品AI中心</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml"> <link rel="icon" href="./favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./src/styles.css"> <link rel="stylesheet" href="./src/styles.css">
</head> </head>
<body> <body>
<a class="skip-link" href="#main">跳转到应用列表</a> <a class="skip-link" href="#main">跳转到智能体列表</a>
<aside class="sidebar"> <div class="app-shell">
<a class="brand" href="./" aria-label="碟雅产品AI中心首页"> <div class="background-grid" aria-hidden="true"></div>
<span class="brand-mark" aria-hidden="true">碟</span> <header class="header">
<span><strong>碟雅</strong><small>产品AI中心</small></span> <a class="brand" href="./" aria-label="蝶雅产品AI中心首页">
</a> <span class="brand-logo" aria-hidden="true">◇</span>
<div class="workspace-label">WORKSPACE <span>工作空间</span></div> <span><strong>蝶雅产品AI中心</strong><small>PRODUCT AI WORKSPACE</small></span>
<nav id="category-nav" aria-label="应用分类"></nav> </a>
<div class="sidebar-bottom"><span class="small-mark" aria-hidden="true">D</span><div><strong>碟雅产品AI中心</strong><small>让工作,从这里开始</small></div></div> <nav class="nav" aria-label="主导航">
</aside> <div class="theme-switch" role="group" aria-label="选择主题">
<div class="page"> <button class="theme-button active" type="button" data-theme="light" aria-label="浅色主题" aria-pressed="true">☼</button>
<header class="topbar"> <button class="theme-button" type="button" data-theme="dark" aria-label="深色主题" aria-pressed="false">☾</button>
<div class="breadcrumb">工作台 <span>/</span> <strong>产品AI中心</strong></div> </div>
<div class="topbar-right"><span class="portal-indicator">统一应用门户</span><span class="avatar" aria-hidden="true">碟</span></div> </nav>
</header> </header>
<main id="main" tabindex="-1"> <main id="main" class="main" tabindex="-1">
<section class="welcome" aria-labelledby="welcome-title"> <section class="intro" aria-labelledby="page-title">
<div class="welcome-copy"><div class="eyebrow"><span></span> DIEYA WORKSPACE</div> <p class="eyebrow">PRODUCT AI CENTER</p>
<h1 id="welcome-title">每一份效率,都有入口。</h1> <h1 id="page-title">选择智能体</h1>
<p>连接 AI 与业务,让常用应用触手可及。</p> <p class="intro-description">选择需要使用的产品工具,立即开始工作。</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>
<section class="application-section" aria-labelledby="section-title"> <section id="agent-grid" class="agent-grid" aria-label="产品部智能体列表"></section>
<div class="section-heading"><div><h2 id="section-title">全部应用</h2><p id="section-description">你的业务工具与 AI 助手,都在这里。</p></div> <section id="empty-state" class="empty-state" hidden aria-live="polite">暂无可用的产品工具。</section>
<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> <footer class="footer"><span>蝶雅产品AI中心</span><span>连接产品 · 提升效率</span></footer>
</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> </main>
</div> </div>
<div id="toast" class="toast" role="status" aria-live="polite"></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> <script type="module" src="./src/main.mjs"></script>
</body> </body>
</html> </html>

View File

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "碟雅产品AI中心", "description": "蝶雅产品AI中心",
"engines": { "node": ">=18" }, "engines": { "node": ">=18" },
"scripts": { "scripts": {
"dev": "node scripts/serve.mjs", "dev": "node scripts/serve.mjs",

View File

@ -23,4 +23,4 @@ const server = http.createServer(async (request, response) => {
} catch { response.writeHead(404); response.end('Not found'); } } catch { response.writeHead(404); response.end('Not found'); }
}); });
server.on('error', error => { console.error(`启动失败:${error.message}`); process.exitCode = 1; }); server.on('error', error => { console.error(`启动失败:${error.message}`); process.exitCode = 1; });
server.listen(port, host, () => console.log(`碟雅产品AI中心:http://${host}:${port}`)); server.listen(port, host, () => console.log(`蝶雅产品AI中心:http://${host}:${port}`));

View File

@ -1,68 +1,29 @@
// 新增或修改应用只需维护此配置。各入口使用用户确认的域名与协议。 // 对外门户只维护产品部正式应用。新增入口时在此数组追加配置。
export const applications = [ export const applications = [
{ {
id: 'operations-ai', name: '碟雅运营AI中心', shortName: '运营', id: 'risk', name: '风险预警', shortName: '风控',
description: '运营 AI 应用统一入口', description: '识别业务风险,快速查看预警信息与处理建议。',
url: 'https://aiagent.dieya.net.cn/', url: 'http://risk-prod.dieya.net.cn/',
category: 'ai', environment: 'production', color: 'blue', icon: 'sparkles' category: 'product', environment: 'production', color: 'violet', icon: 'shield'
},
{
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: '成本', id: 'cost', name: '成本识别', shortName: '成本',
description: '成本识别业务应用入口', description: '辅助产品团队识别成本结构,提升评估与决策效率。',
url: 'http://cost.dieya.net.cn/', url: 'http://cost.dieya.net.cn/',
category: 'tools', environment: 'production', color: 'blue', icon: 'scan' category: 'product', 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 = [ export const categories = [
{ id: 'all', name: '全部应用', icon: 'grid' }, { id: 'all', name: '全部工具', icon: 'grid' },
{ id: 'ai', name: 'AI 助手', icon: 'sparkles' }, { id: 'product', name: '产品部工作台', icon: 'layers' },
{ id: 'tools', name: '业务工具', icon: 'clipboard' },
{ id: 'risk', name: '风险管理', icon: 'shield' },
{ id: 'favorites', name: '我的常用', icon: 'star' } { id: 'favorites', name: '我的常用', icon: 'star' }
]; ];
export function filterApplications(apps, { query = '', category = 'all', environment = 'all', favorites = [] } = {}) { export function filterApplications(apps, { query = '', category = 'all', favorites = [] } = {}) {
const term = query.trim().toLocaleLowerCase(); const term = query.trim().toLocaleLowerCase();
return apps.filter(app => return apps.filter(app =>
(category === 'all' || (category === 'favorites' ? favorites.includes(app.id) : app.category === category)) && (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))) (!term || [app.name, app.description, app.url].some(value => value.toLocaleLowerCase().includes(term)))
); );
} }
@ -76,8 +37,9 @@ export function validateApplications(apps) {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error(`应用地址无效:${app.name}`); throw new Error(`应用地址无效:${app.name}`);
} }
if (!app.name || !app.description || !['ai', 'tools', 'risk'].includes(app.category) || if (!app.name || !app.description || app.category !== 'product' || app.environment !== 'production') {
!['production', 'test'].includes(app.environment)) throw new Error(`应用配置不完整:${app.id}`); throw new Error(`应用配置不完整:${app.id}`);
}
} }
return true; return true;
} }

View File

@ -1,110 +1,43 @@
import { applications, categories, filterApplications, validateApplications } from './apps.mjs'; import { applications, validateApplications } from './apps.mjs';
validateApplications(applications); validateApplications(applications);
const icons = { 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"/>', 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"/>', 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"/>',
arrow: '<path d="M5 12h14m-5-5 5 5-5 5"/>', 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"/>' external: '<path d="M14 5h5v5M19 5l-8 8"/><path d="M18 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>'
}; };
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 svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.scan}</svg>`;
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const storageKey = 'dieya-portal:favorites:v1'; const grid = document.querySelector('#agent-grid');
function readFavorites() { const root = document.documentElement;
try { const themeKey = 'dieya-portal:theme:v1';
const stored = JSON.parse(localStorage.getItem(storageKey) || '[]'); const storedTheme = localStorage.getItem(themeKey);
return Array.isArray(stored) ? stored.filter(id => applications.some(app => app.id === id)) : []; const preferredTheme = storedTheme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
} 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() { function applyTheme(theme) {
nav.innerHTML = categories.map(category => { root.dataset.theme = theme;
const count = category.id === 'favorites' ? state.favorites.length : category.id === 'all' ? applications.length : applications.filter(app => app.category === category.id).length; document.querySelectorAll('[data-theme]').forEach(button => {
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>`; const active = button.dataset.theme === theme;
}).join(''); button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
document.querySelector('meta[name="theme-color"]').setAttribute('content', theme === 'dark' ? '#070b17' : '#f5f8fc');
try { localStorage.setItem(themeKey, theme); } catch { /* 当前浏览器不支持持久化时仍可切换 */ }
} }
function render() { function render() {
renderNav(); grid.innerHTML = applications.map((app, index) => `<article class="agent-card agent-card--${escapeHtml(app.color)}">
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(''); <div class="agent-card__top"><span class="agent-card__icon">${svg(app.icon)}</span><span class="agent-card__category">产品部</span></div>
document.querySelector('#section-title').textContent = categories.find(category => category.id === state.category).name; <h2 class="agent-card__title">${escapeHtml(app.name)} <span class="agent-card__guide">正式应用</span></h2>
document.querySelector('#section-description').textContent = state.category === 'favorites' ? '收藏你常用的应用,下次更快找到。' : '你的业务工具与 AI 助手,都在这里。'; <p class="agent-card__description">${escapeHtml(app.description)}</p>
const matches = filterApplications(applications, state); <div class="agent-card__meta"><span class="ready-dot"></span><span>就绪</span><span class="agent-card__index">0${index + 1}</span></div>
document.querySelector('#result-count').textContent = `共 ${matches.length} 个应用`; <a class="agent-card__button" href="${escapeHtml(app.url)}">打开工作台 ${svg('arrow')}</a>
grid.innerHTML = matches.map(app => { <span class="agent-card__external" aria-hidden="true">${svg('external')}</span>
const favorite = state.favorites.includes(app.id); </article>`).join('');
const categoryName = categories.find(category => category.id === app.category).name; document.querySelector('#empty-state').hidden = applications.length > 0;
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 => { document.querySelectorAll('[data-theme]').forEach(button => button.addEventListener('click', () => applyTheme(button.dataset.theme)));
const button = event.target.closest('[data-category]'); applyTheme(preferredTheme);
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(); render();

File diff suppressed because one or more lines are too long

View File

@ -1,37 +1,29 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { applications, filterApplications, validateApplications } from '../src/apps.mjs'; import { applications, categories, filterApplications, validateApplications } from '../src/apps.mjs';
test('八个业务地址与用户给定映射一致,正式和测试风险入口不混淆', () => { test('门户只展示产品部两个正式应用', () => {
const expected = { assert.deepEqual(Object.fromEntries(applications.map(app => [app.name, app.url])), {
'碟雅运营AI中心': 'https://aiagent.dieya.net.cn/', '风险预警': 'http://risk-prod.dieya.net.cn/',
'碟雅AI中心测试版': 'https://aiagent1.dieya.net.cn/', '成本识别': 'http://cost.dieya.net.cn/'
'需求收集': 'http://demand.dieya.net.cn/', });
'财务AI助手测试': 'http://finance-test.dieya.net.cn/', assert.equal(applications.length, 2);
'成本识别': 'http://cost.dieya.net.cn/', assert.equal(applications.every(app => app.category === 'product' && app.environment === 'production'), true);
'违禁词助手': 'http://prohibite.dieya.net.cn/', assert.equal(applications.some(app => /测试|运营|需求|财务|违禁|制度/.test(app.name)), false);
'风险预警测试': 'http://risk.dieya.net.cn/', assert.deepEqual(categories.map(category => category.id), ['all', 'product', 'favorites']);
'风险预警': '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(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']); test('分类、关键词和常用筛选同时生效', () => {
assert.equal(filterApplications(applications, { query: ' 风险预警 ' }).length, 2); assert.deepEqual(filterApplications(applications, { category: 'product' }).map(app => app.id), ['risk', 'cost']);
assert.deepEqual(filterApplications(applications, { query: 'AIAGENT1.DIEYA' }).map(app => app.id), ['ai-center-test']); assert.deepEqual(filterApplications(applications, { query: '成本' }).map(app => app.id), ['cost']);
assert.equal(filterApplications(applications, { query: '不存在的应用' }).length, 0); assert.deepEqual(filterApplications(applications, { query: 'RISK-PROD' }).map(app => app.id), ['risk']);
}); assert.deepEqual(filterApplications(applications, { category: 'favorites', favorites: ['cost'] }).map(app => app.id), ['cost']);
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); assert.equal(filterApplications(applications, { category: 'favorites', favorites: [] }).length, 0);
}); });
test('拒绝非网页链接、重复标识和不完整配置', () => {
test('拒绝不安全地址、重复标识和测试入口配置', () => {
assert.throws(() => validateApplications([{ ...applications[0], url: 'javascript:alert(1)' }])); assert.throws(() => validateApplications([{ ...applications[0], url: 'javascript:alert(1)' }]));
assert.throws(() => validateApplications([applications[0], applications[0]])); assert.throws(() => validateApplications([applications[0], applications[0]]));
assert.throws(() => validateApplications([{ ...applications[0], environment: 'unknown' }])); assert.throws(() => validateApplications([{ ...applications[0], environment: 'test' }]));
}); });