commit 8d36ac96ee33de718744f2165fed0c6bd29ce47a Author: kx_guo Date: Fri Oct 23 14:36:16 2026 +0800 feat: add Dieya product AI center portal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2824b07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..c091319 --- /dev/null +++ b/README.md @@ -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,应在配置中更新并重新验证。 + +## 集成范围 + +本项目完成的是应用入口统一,未合并原业务代码、数据库、登录会话或数据权限。所有卡片对能访问门户的人可见;本项目不是访问控制系统。如果后续需要按部门/角色展示卡片,需要增加门户后端或接入现有身份服务,且目标系统仍应自行鉴权。 + +已验证静态构建、入口映射与筛选逻辑;目标系统的线上可用性和钉钉内免登需在企业实际环境联调。 diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..10a2cca --- /dev/null +++ b/deploy/nginx.conf.example @@ -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"; + } +} diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..a18a0fc --- /dev/null +++ b/favicon.svg @@ -0,0 +1 @@ +碟 diff --git a/index.html b/index.html new file mode 100644 index 0000000..57103a9 --- /dev/null +++ b/index.html @@ -0,0 +1,52 @@ + + + + + + + + 碟雅产品AI中心 + + + + + + +
+
+ +
统一应用门户
+
+
+
+
DIEYA WORKSPACE
+

每一份效率,都有入口。

+

连接 AI 与业务,让常用应用触手可及。

+
8 个应用一个入口,开启工作
+
+ +
+
+

全部应用

你的业务工具与 AI 助手,都在这里。

+ +
+
+
+ +
+
碟雅产品AI中心连接业务 · 提升效率
+
+
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a43660 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..c779fae --- /dev/null +++ b/scripts/build.mjs @@ -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} 个应用)`); diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..ee0ce27 --- /dev/null +++ b/scripts/serve.mjs @@ -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}`)); diff --git a/src/apps.mjs b/src/apps.mjs new file mode 100644 index 0000000..bb6271b --- /dev/null +++ b/src/apps.mjs @@ -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; +} diff --git a/src/main.mjs b/src/main.mjs new file mode 100644 index 0000000..f007d4d --- /dev/null +++ b/src/main.mjs @@ -0,0 +1,110 @@ +import { applications, categories, filterApplications, validateApplications } from './apps.mjs'; + +validateApplications(applications); +const icons = { + grid: '', + sparkles: '', + clipboard: '', + wallet: '', + scan: '', + text: '', + shield: '', + star: '', + arrow: '', + search: '' +}; +const svg = name => ``; +const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ``; + }).join(''); +} + +function render() { + renderNav(); + filters.innerHTML = environmentLabels.map(item => ``).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 `
+
${svg(app.icon)}
+

${escapeHtml(app.name)}

+

${escapeHtml(app.description)}

+
${escapeHtml(categoryName)}${app.environment === 'test' ? '测试' : '正式'}
+
${escapeHtml(new URL(app.url).hostname)}
+
`; + }).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(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..389d69a --- /dev/null +++ b/src/styles.css @@ -0,0 +1,9 @@ +:root{font-family:Inter,"Microsoft YaHei","PingFang SC",system-ui,sans-serif;color:#202a3f;background:#f6f8fc;font-synthesis:none;--blue:#215ae8;--muted:#8b94a6;--line:#e9edf4} +*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button,a{touch-action:manipulation}button{cursor:pointer}a{color:inherit;text-decoration:none}button{color:inherit}svg{width:22px;height:22px;display:block}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #7297ff;outline-offset:4px}button{border:0}button:hover{transition:background .15s,color .15s} [hidden]{display:none!important} +.skip-link{position:fixed;top:-100px;left:20px;z-index:20;background:white;padding:12px;border-radius:8px}.skip-link:focus{top:10px}.sidebar{width:224px;position:fixed;inset:0 auto 0 0;background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column;padding:32px 20px 22px}.brand{display:flex;align-items:center;gap:12px;margin:0 9px 48px}.brand-mark{display:grid;place-items:center;width:44px;height:44px;border-radius:13px;background:var(--blue);color:white;font-size:25px;font-weight:600;box-shadow:0 5px 12px #215ae826}.brand strong{font-size:21px;letter-spacing:3px;display:block;line-height:1.3}.brand small{display:block;font-size:11px;color:#8a94a8;letter-spacing:2px;margin-top:3px}.workspace-label{font-size:10px;letter-spacing:1.3px;color:#a3acbc;padding:0 13px;margin-bottom:18px}.workspace-label span{display:block;font-size:11px;letter-spacing:1px;margin-top:6px;color:#8c96a9}.nav-item{background:transparent;display:flex;width:100%;align-items:center;gap:12px;padding:14px 13px;margin-bottom:8px;border-radius:9px;color:#737e94;text-align:left;font-size:13px;min-height:48px}.nav-item svg{width:19px;height:19px;flex-shrink:0}.nav-item span{flex:1}.nav-item small{font-size:11px;color:#a6aebd}.nav-item:hover{background:#f5f7fc}.nav-item.active{color:var(--blue);background:#edf3ff;font-weight:600}.nav-item.active small{color:var(--blue)}.sidebar-bottom{border-top:1px solid var(--line);margin-top:auto;padding:22px 9px 0;display:flex;gap:10px;align-items:center}.small-mark{display:grid;place-items:center;background:#f2f5fa;color:#8594b0;border-radius:9px;width:33px;height:33px;font-weight:600;font-size:15px}.sidebar-bottom strong{font-size:11px;font-weight:500;display:block}.sidebar-bottom small{font-size:10px;color:#98a1b2;display:block;margin-top:5px}.page{margin-left:224px}.topbar{height:80px;border-bottom:1px solid var(--line);background:#ffffffd9;display:flex;align-items:center;justify-content:space-between;padding:0 44px}.breadcrumb{font-size:12px;color:#9aa3b3;display:flex;gap:16px}.breadcrumb span{color:#d3d9e3}.breadcrumb strong{font-weight:500;color:#4f5d77}.topbar-right{display:flex;align-items:center;gap:23px}.portal-indicator{font-size:11px;color:#8894a9}.avatar{width:32px;height:32px;display:grid;place-items:center;color:#5a73b0;background:#eef2fc;border-radius:50%;font-size:14px}main{max-width:1490px;margin:auto;padding:36px 44px 0;outline:0}.welcome{position:relative;overflow:hidden;min-height:224px;border:1px solid #dfe8fc;border-radius:16px;background:linear-gradient(105deg,#edf3ff 0%,#f0f5ff 58%,#e6edff 100%);display:flex;align-items:center;padding:32px 35px}.welcome-copy{position:relative;z-index:1}.eyebrow{font-size:10px;letter-spacing:2.7px;font-weight:600;color:#5477be;display:flex;align-items:center;gap:9px}.eyebrow>span{width:6px;height:6px;border-radius:50%;background:#5485ee}h1{font-size:29px;line-height:1.6;letter-spacing:1px;margin:12px 0 7px;font-weight:600;color:#203967}.welcome p{font-size:12px;color:#7a8ba8;margin:0}.hero-meta{display:flex;align-items:center;gap:14px;color:#8392ae;font-size:10px;margin-top:24px}.hero-meta strong{font-size:14px;font-weight:600;color:#476eb5;margin-right:3px}.hero-meta>i{height:10px;width:1px;background:#c8d5ee}.hero-art{position:absolute;right:12px;top:0;width:340px;height:240px;pointer-events:none}.orbit{position:absolute;border:1px solid #a6c0fb45;border-radius:50%;transform:rotate(-25deg)}.orbit-one{width:282px;height:172px;top:37px;left:25px}.orbit-two{width:330px;height:210px;top:19px;left:1px}.art-tile{position:absolute;display:grid;place-items:center;box-shadow:0 16px 24px #5c7cd421}.tile-one{width:88px;height:88px;border:1px solid #fff;background:linear-gradient(145deg,#fff,#e9f0ff);border-radius:23px;left:46px;top:69px;transform:rotate(-13deg);color:#376deb;font-size:28px;font-weight:600}.tile-one span{position:absolute;font-size:17px;right:12px;top:7px}.tile-two{width:98px;height:98px;background:linear-gradient(135deg,#568dff,#2457dc);border:1px solid #739aff;border-radius:26px;left:141px;top:89px;transform:rotate(12deg);color:#fff;font-size:51px;font-weight:600}.tile-three{width:52px;height:52px;background:#ffffffbc;border:1px solid #fff;border-radius:15px;left:211px;top:29px;transform:rotate(14deg);color:#6387e5;font-size:27px}.art-dot{position:absolute;border-radius:50%;background:#8caafa}.dot-one{width:7px;height:7px;top:42px;left:104px}.dot-two{width:5px;height:5px;top:181px;left:273px;background:#b7c9f3}.application-section{margin-top:36px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.section-heading h2{font-size:19px;margin:0 0 8px;font-weight:600;letter-spacing:.5px}.section-heading p{font-size:11px;color:#929cae;margin:0}.search-box{border:1px solid #e4e9f2;border-radius:8px;background:#fff;display:flex;align-items:center;height:40px;width:280px;padding:0 12px;gap:9px;flex-shrink:0}.search-box:focus-within{border-color:#799cf5;box-shadow:0 0 0 3px #215ae80c}.search-box svg{height:17px;width:17px;color:#9ba6ba}.search-box input{border:0;background:transparent;outline:0;width:100%;min-width:0;font-size:11px;color:#344362}.search-box input:focus-visible{outline:0}.search-box input::placeholder{color:#a6aebc}.search-box kbd{border:1px solid #e6eaf1;border-radius:4px;font-size:11px;width:20px;text-align:center;color:#abb3c1;line-height:20px}.filter-row{display:flex;justify-content:space-between;align-items:center;margin:24px 0 20px;gap:16px}.environment-filters{display:flex;gap:6px}.filter-button{border-radius:6px;font-size:11px;padding:8px 14px;background:transparent;color:#8b95a8;min-height:34px}.filter-button:hover{background:#eaf0fb}.filter-button.active{background:#e8efff;color:#3160cf;font-weight:600}.result-count{font-size:11px;color:#9aa3b3}.app-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.app-card{position:relative;min-width:0;border:1px solid #e7ebf3;background:#fff;border-radius:12px;padding:22px 22px 0;transition:transform .18s,box-shadow .18s,border-color .18s}.app-card:hover{transform:translateY(-3px);border-color:#c5d6fc;box-shadow:0 10px 25px #1d4f9c0b}.card-top{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:18px}.app-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:12px}.app-icon svg{width:25px;height:25px;stroke-width:1.55}.blue{background:#edf3ff;color:#3976f6}.violet{background:#f2edff;color:#8a66dc}.teal{background:#e9f8f5;color:#31a996}.amber{background:#fff5e6;color:#d9a147}.favorite-button{position:relative;z-index:2;padding:9px;margin:-7px -7px 0 0;background:transparent;border-radius:7px;color:#cbd2df;line-height:0}.favorite-button svg{width:17px;height:17px}.favorite-button:hover{color:#d5a442;background:#fff8eb}.favorite-button.selected{color:#dda945}.favorite-button.selected svg{fill:#f6d99c}.app-card h3{font-size:14px;font-weight:600;color:#2c3953;margin:0 0 8px;line-height:1.5}.app-link::after{content:"";position:absolute;inset:0;border-radius:12px;z-index:1}.app-link:focus-visible{outline:0}.app-card:has(.app-link:focus-visible){outline:3px solid #7297ff;outline-offset:3px}.app-description{font-size:11px;color:#9aa3b4;margin:0;line-height:1.8}.card-tags{display:flex;align-items:center;gap:8px;margin-top:17px;margin-bottom:19px;font-size:9px;height:20px}.category-tag{background:#f5f7fa;color:#8b96a9;border-radius:4px;padding:4px 7px}.test-tag{color:#c4953f;background:#fff8e9;border-radius:4px;padding:4px 7px}.production-tag{display:flex;align-items:center;gap:4px;color:#96a3b4;font-size:9px}.production-tag i{height:4px;width:4px;background:#68bfa3;border-radius:50%}.card-bottom{border-top:1px solid #f0f2f7;display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:44px}.app-domain{font-size:10px;letter-spacing:.1px;color:#a2abbc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.launch-icon{color:#a9b6ce;flex-shrink:0}.launch-icon svg{width:17px;height:17px}.app-card:hover .launch-icon{color:var(--blue)}.footer{padding:30px 0 25px;display:flex;justify-content:space-between;color:#aeb6c4;font-size:10px;letter-spacing:.4px}.empty-state{text-align:center;padding:65px 20px 80px;background:#fff;border:1px dashed #dfe5f0;border-radius:12px}.empty-state>div{background:#f1f5fe;border-radius:50%;width:52px;height:52px;display:grid;place-items:center;color:#8fa6da;margin:0 auto 18px}.empty-state h3{font-size:15px;font-weight:500}.empty-state p{font-size:12px;color:#929caf;line-height:1.8}.reset-button{font-size:12px;margin-top:12px;padding:10px 18px;background:#edf3ff;color:var(--blue);border-radius:7px}.toast{position:fixed;bottom:30px;left:calc(50% + 112px);transform:translate(-50%,12px);padding:12px 20px;border-radius:10px;background:#25334beb;color:#fff;font-size:12px;box-shadow:0 5px 20px #111a3026;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:10;max-width:90vw;text-align:center}.toast.visible{opacity:1;transform:translate(-50%,0)}.noscript{position:fixed;bottom:0;background:#fff1d4;padding:20px;width:100%;z-index:20;text-align:center} +.skip-link{top:10px;clip-path:inset(50%);width:1px;height:1px;overflow:hidden;padding:0}.skip-link:focus{clip-path:none;width:auto;height:auto;overflow:visible;padding:12px} +@media(min-width:1600px){.app-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.hero-art{right:60px}} +@media(max-width:1150px){.sidebar{width:194px;padding-left:14px;padding-right:14px}.page{margin-left:194px}.topbar{padding:0 28px}main{padding:28px 28px 0}.app-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.hero-art{opacity:.7;right:-32px}h1{font-size:25px}.toast{left:calc(50% + 97px)}} +@media(max-width:760px){.sidebar{position:static;width:auto;padding:18px 20px 0;border-right:0;border-bottom:1px solid var(--line)}.brand{margin:0 0 18px;gap:9px}.brand-mark{width:34px;height:34px;border-radius:10px;font-size:20px}.brand strong{font-size:18px}.brand small{font-size:9px;letter-spacing:1.5px}.workspace-label,.sidebar-bottom{display:none}#category-nav{display:flex;gap:5px;overflow-x:auto;padding:0 0 10px;scrollbar-width:none}#category-nav::-webkit-scrollbar{display:none}.nav-item{width:auto;white-space:nowrap;font-size:12px;padding:10px 12px;min-height:40px;margin:0;gap:7px;flex-shrink:0}.nav-item small{font-size:10px}.nav-item svg{height:16px;width:16px}.page{margin-left:0}.topbar{height:49px;padding:0 22px}.breadcrumb{font-size:10px;gap:10px}.portal-indicator{font-size:10px}.avatar{display:none}.topbar-right{gap:0}main{padding:20px 20px 0}.welcome{padding:25px 23px;min-height:196px;border-radius:12px}.eyebrow{font-size:8px;letter-spacing:2px}h1{font-size:23px;letter-spacing:0;margin-top:14px}.welcome p{font-size:11px}.hero-meta{font-size:9px;margin-top:21px;gap:10px}.hero-art{opacity:.25;right:-112px;transform:scale(.85);transform-origin:center}.welcome-copy{z-index:1}.section-heading{display:block}.application-section{margin-top:27px}.section-heading h2{font-size:17px}.section-heading p{font-size:10px}.search-box{margin-top:20px;width:100%;height:42px}.search-box input{font-size:12px}.search-box kbd{display:none}.filter-row{margin:17px 0 15px;gap:8px}.filter-button{font-size:10px;padding:8px 10px}.environment-filters{gap:3px}.result-count{font-size:10px}.app-grid{gap:12px}.app-card{padding:16px 15px 0;border-radius:10px}.card-top{margin-bottom:14px}.app-icon{width:37px;height:37px;border-radius:10px}.app-icon svg{width:22px;height:22px}.app-card h3{font-size:12px;min-height:36px;margin-bottom:4px}.app-description{font-size:10px;line-height:1.7;min-height:34px}.card-tags{margin:13px 0 15px;gap:5px}.app-domain{font-size:8px;letter-spacing:0}.card-bottom{min-height:38px;gap:3px}.launch-icon svg{width:14px;height:14px}.footer{font-size:9px;padding-top:25px}.toast{left:50%}.favorite-button{padding:10px}.favorite-button svg{width:16px;height:16px}} +@media(max-width:360px){main{padding-left:14px;padding-right:14px}.sidebar{padding-left:14px;padding-right:14px}.welcome{padding-left:18px}h1{font-size:21px}.app-grid{grid-template-columns:1fr}.app-card h3,.app-description{min-height:0}.app-domain{font-size:10px}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}} diff --git a/tests/apps.test.mjs b/tests/apps.test.mjs new file mode 100644 index 0000000..1cd773a --- /dev/null +++ b/tests/apps.test.mjs @@ -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' }])); +});