Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import { ForbiddenException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { AuthUser } from './decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
export type DataScope = 'ALL' | 'DEPT' | 'SELF';
const RANK: Record<DataScope, number> = { SELF: 0, DEPT: 1, ALL: 2 };
export function mergeDataScope(scopes: string[]): DataScope {
let best: DataScope = 'SELF';
for (const s of scopes) {
const v = s as DataScope;
if (RANK[v] > RANK[best]) best = v;
}
return best;
}
export function effectiveScope(user: AuthUser): DataScope {
if (user.roles.includes('admin') || user.roles.includes('owner')) return 'ALL';
return user.dataScope || 'SELF';
}
export async function descendantDeptIds(prisma: PrismaService, rootId?: string | null) {
if (!rootId) return [] as string[];
const all = await prisma.department.findMany({
where: { tenantId: '1' },
select: { id: true, parentId: true },
});
const ids = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const d of all) {
if (d.parentId && ids.has(d.parentId) && !ids.has(d.id)) {
ids.add(d.id);
grew = true;
}
}
}
return [...ids];
}
function empInScope(user: AuthUser, deptIds: string[]): Prisma.EmployeeWhereInput {
return {
OR: [
{ departmentId: { in: deptIds } },
...(user.employeeId
? [{ managerId: user.employeeId }, { managerIds: { contains: user.employeeId } }]
: []),
],
};
}
/** 本人 / 本部门及下级 / 全公司 */
export async function ownerScope(
prisma: PrismaService,
user: AuthUser,
owner: 'createdById' | 'applicantId',
): Promise<Prisma.BidCaseWhereInput | Prisma.ExpenseClaimWhereInput | Record<string, never>> {
const scope = effectiveScope(user);
if (scope === 'ALL') return {};
if (scope === 'SELF' || !user.departmentId) return { [owner]: user.id };
const deptIds = await descendantDeptIds(prisma, user.departmentId);
const rel = owner === 'createdById' ? 'createdBy' : 'applicant';
return {
OR: [{ [owner]: user.id }, { [rel]: { employee: empInScope(user, deptIds) } }],
};
}
export function withAnd<T extends { AND?: unknown }>(where: T, extra: Record<string, unknown>): T {
if (!extra || !Object.keys(extra).length) return where;
const prev = where.AND;
const list = Array.isArray(prev) ? [...prev] : prev ? [prev] : [];
list.push(extra);
where.AND = list;
return where;
}
export async function canSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (!ownerId) return true;
const scope = effectiveScope(user);
if (scope === 'ALL') return true;
if (ownerId === user.id) return true;
if (scope !== 'DEPT' || !user.departmentId) return false;
const owner = await prisma.user.findUnique({
where: { id: ownerId },
include: { employee: true },
});
if (!owner?.employee) return false;
const deptIds = await descendantDeptIds(prisma, user.departmentId);
if (owner.employee.departmentId && deptIds.includes(owner.employee.departmentId)) return true;
const extra = String(owner.employee.managerIds || '')
.split(/[,,、\s]+/)
.filter(Boolean);
return Boolean(
user.employeeId &&
(owner.employee.managerId === user.employeeId || extra.includes(user.employeeId)),
);
}
export async function assertCanSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (await canSeeOwner(prisma, user, ownerId)) return;
throw new ForbiddenException('超出数据范围,无权查看');
}
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...codes: string[]) =>
SetMetadata(PERMISSIONS_KEY, codes);
@@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser {
id: string;
username: string;
displayName: string;
permissions: string[];
roles: string[];
employeeId?: string | null;
departmentId?: string | null;
dataScope: 'ALL' | 'DEPT' | 'SELF';
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest().user as AuthUser;
},
);
+15
View File
@@ -0,0 +1,15 @@
import { nextSerial } from './doc-no';
describe('nextSerial', () => {
it('starts at 00001 when empty', () => {
expect(nextSerial([], 'BX-2026-')).toBe('BX-2026-00001');
});
it('uses max+1 so a hole in the middle does not collide', () => {
expect(nextSerial(['BX-2026-00001', 'BX-2026-00005'], 'BX-2026-')).toBe('BX-2026-00006');
});
it('ignores old date-style numbers that share the year digits', () => {
expect(nextSerial(['BX-20260617-005', 'BX-2026-00002'], 'BX-2026-')).toBe('BX-2026-00003');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { Prisma } from '@prisma/client';
const DOC_NO_LOCK: Record<string, number> = { BX: 91001, JK: 91002, BZ: 91003 };
export function nextSerial(existing: string[], prefix: string, pad = 5) {
let max = 0;
for (const no of existing) {
if (!no.startsWith(prefix)) continue;
const tail = no.slice(prefix.length);
if (/^\d+$/.test(tail)) {
const n = Number(tail);
if (Number.isFinite(n)) max = Math.max(max, n);
}
}
return `${prefix}${String(max + 1).padStart(pad, '0')}`;
}
export function isUniqueConflict(e: unknown) {
return e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002';
}
export async function withUniqueRetry<T>(fn: () => Promise<T>, times = 8): Promise<T> {
let last: unknown;
for (let i = 0; i < times; i += 1) {
try {
return await fn();
} catch (e) {
last = e;
if (!isUniqueConflict(e)) throw e;
}
}
throw last instanceof Error ? last : new Error('单号冲突,请重试');
}
type LockTx = { $executeRaw: Prisma.TransactionClient['$executeRaw'] };
export async function lockDocNo(tx: LockTx, prefix: 'BX' | 'JK' | 'BZ') {
const key = DOC_NO_LOCK[prefix];
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${key}::bigint)`;
}
type ClaimDb = {
expenseClaim: {
findMany: (args: {
where: { tenantId: string; claimNo: { startsWith: string } };
select: { claimNo: true };
}) => Promise<{ claimNo: string }[]>;
};
};
export async function nextExpenseClaimNo(tx: LockTx & ClaimDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BX');
const prefix = `BX-${year}-`;
const rows = await tx.expenseClaim.findMany({
where: { tenantId: '1', claimNo: { startsWith: prefix } },
select: { claimNo: true },
});
return nextSerial(
rows.map((r) => r.claimNo),
prefix,
);
}
type BondDb = {
bondRecord: {
findMany: (args: {
where: { tenantId: string; bondNo: { startsWith: string } };
select: { bondNo: true };
}) => Promise<{ bondNo: string }[]>;
};
};
export async function nextBondNo(tx: LockTx & BondDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BZ');
const prefix = `BZ-${year}-`;
const rows = await tx.bondRecord.findMany({
where: { tenantId: '1', bondNo: { startsWith: prefix } },
select: { bondNo: true },
});
return nextSerial(
rows.map((r) => r.bondNo),
prefix,
);
}
+24
View File
@@ -0,0 +1,24 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class PageQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 10;
@IsOptional()
keyword?: string;
}
export function pageMeta(page: number, pageSize: number, total: number) {
return { page, pageSize, total };
}
@@ -0,0 +1,84 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Prisma } from '@prisma/client';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
import { PrismaService } from '../../prisma/prisma.service';
import { LOG_ACTION, writeOpLog } from '../../system/op-log';
import type { AuthUser } from '../decorators/current-user.decorator';
function publicErrorMessage(exception: unknown) {
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') return '单号已被占用,请再试一次';
return '保存失败,请稍后重试';
}
if (exception instanceof Error) {
if (/Invalid `prisma\.|Unique constraint failed/i.test(exception.message)) {
return '单号已被占用,请再试一次';
}
return exception.message;
}
return '服务器内部错误';
}
@Catch()
@Injectable()
export class HttpErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpErrorFilter.name);
constructor(private readonly prisma: PrismaService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request & { user?: AuthUser }>();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const payload = exception.getResponse();
if (typeof payload === 'string') {
message = payload;
} else if (typeof payload === 'object' && payload && 'message' in payload) {
const raw = (payload as { message: string | string[] }).message;
message = Array.isArray(raw) ? raw.join('; ') : raw;
}
} else {
message = publicErrorMessage(exception);
}
this.logger.error(
`${req.method} ${req.url} ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
if (status === HttpStatus.FORBIDDEN) {
void writeOpLog(this.prisma, {
userId: req.user?.id,
username: req.user?.username,
action: LOG_ACTION.FORBIDDEN,
path: `${req.method} ${req.originalUrl || req.url}`,
detail: message,
success: false,
requestId,
ip: req.ip,
});
}
res.status(status).json({
code: status,
message,
data: null,
requestId,
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/auth.decorators';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>('permissions', [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const user = context.switchToHttp().getRequest().user;
if (!user?.permissions) return false;
if (user.roles?.includes('admin')) return true;
return required.every((code) => user.permissions.includes(code));
}
}
@@ -0,0 +1,40 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
requestId: string;
}
@Injectable()
export class ResponseInterceptor<T>
implements NestInterceptor<T, ApiResponse<T> | StreamableFile>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T> | StreamableFile> {
const req = context.switchToHttp().getRequest();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
return next.handle().pipe(
map((data) => {
if (data instanceof StreamableFile) return data;
return {
code: 0,
message: 'ok',
data: (data ?? null) as T,
requestId,
};
}),
);
}
}
@@ -0,0 +1,15 @@
import { randomUUID } from 'crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
export const REQUEST_ID_HEADER = 'x-request-id';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const id = (req.headers[REQUEST_ID_HEADER] as string) || randomUUID();
req.headers[REQUEST_ID_HEADER] = id;
res.setHeader(REQUEST_ID_HEADER, id);
next();
}
}
+42
View File
@@ -0,0 +1,42 @@
import { assembleApprovalChain } from './org-chain';
const staff = { id: 'staff', displayName: '员工甲', username: 'a' };
const pm = { id: 'pm', displayName: '项目经理', username: 'pm' };
const dir = { id: 'dir', displayName: '研发总监', username: 'rd' };
const pan = { id: 'pan', displayName: '潘兴辉', username: 'pan' };
const biz = { id: 'biz', displayName: '王迪', username: 'wangdi' };
describe('assembleApprovalChain', () => {
it('staff goes PM → dept director → 潘兴辉 → 商务总监', () => {
expect(
assembleApprovalChain({
applicantId: staff.id,
manager: pm,
director: dir,
pan,
bizDirector: biz,
}).map((s) => `${s.role}:${s.user.displayName}`),
).toEqual(['项目经理:项目经理', '部门总监:研发总监', '潘兴辉:潘兴辉', '商务总监:王迪']);
});
it('skips the applicant and duplicate people', () => {
expect(
assembleApprovalChain({
applicantId: pm.id,
manager: pm,
director: dir,
pan,
bizDirector: biz,
}).map((s) => s.role),
).toEqual(['部门总监', '潘兴辉', '商务总监']);
expect(
assembleApprovalChain({
applicantId: staff.id,
manager: pm,
director: biz,
pan,
bizDirector: biz,
}).map((s) => s.role),
).toEqual(['项目经理', '部门总监', '潘兴辉']);
});
});
+209
View File
@@ -0,0 +1,209 @@
import type { Prisma } from '@prisma/client';
import { DIRECTOR_ROLES, TECH_LEAD_ROLES } from './role-groups';
import { PrismaService } from '../prisma/prisma.service';
export const PERSON_SELECT = { id: true, displayName: true, username: true } as const;
export type OrgPerson = { id: string; displayName: string; username: string };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parsePeopleIds(raw?: string | null) {
if (!raw) return [] as string[];
return [
...new Set(
raw
.split(/[,,、\s]+/)
.map((s) => s.trim())
.filter((s) => UUID_RE.test(s)),
),
];
}
type OrgDb = PrismaService | Prisma.TransactionClient;
const PAN_NAME = '潘兴辉';
export function assembleApprovalChain(input: {
applicantId: string;
manager: OrgPerson | null;
director: OrgPerson | null;
pan: OrgPerson | null;
bizDirector: OrgPerson | null;
}): { role: string; user: OrgPerson }[] {
const steps: { role: string; user: OrgPerson }[] = [];
const push = (role: string, user: OrgPerson | null | undefined) => {
if (!user) return;
if (user.id === input.applicantId) return;
if (steps.some((s) => s.user.id === user.id)) return;
steps.push({ role, user });
};
push('项目经理', input.manager);
push('部门总监', input.director);
push('潘兴辉', input.pan);
push('商务总监', input.bizDirector);
return steps;
}
export async function ancestorDeptIds(db: OrgDb, startId?: string | null) {
if (!startId) return [] as string[];
const all = await db.department.findMany({
where: { tenantId: '1' },
select: { id: true, parentId: true },
});
const ids: string[] = [];
const seen = new Set<string>();
let cur: string | null | undefined = startId;
while (cur && !seen.has(cur)) {
seen.add(cur);
ids.push(cur);
cur = all.find((d) => d.id === cur)?.parentId;
}
return ids;
}
export async function findDeptDirector(db: OrgDb, departmentId?: string | null): Promise<OrgPerson | null> {
const chain = await ancestorDeptIds(db, departmentId);
for (const deptId of chain) {
const tech = await db.user.findFirst({
where: {
tenantId: '1',
status: 'ACTIVE',
employee: { departmentId: deptId },
roles: { some: { role: { code: { in: [...TECH_LEAD_ROLES] } } } },
},
select: PERSON_SELECT,
});
if (tech) return tech;
const any = await db.user.findFirst({
where: {
tenantId: '1',
status: 'ACTIVE',
employee: { departmentId: deptId },
roles: { some: { role: { code: { in: [...DIRECTOR_ROLES] } } } },
},
select: PERSON_SELECT,
});
if (any) return any;
}
return null;
}
export async function findNamedPerson(db: OrgDb, name: string): Promise<OrgPerson | null> {
const users = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
OR: [{ displayName: { contains: name } }, { employee: { name: { contains: name } } }],
},
select: { ...PERSON_SELECT, employee: { select: { name: true } } },
});
const exact = users.find((u) => u.displayName === name || u.employee?.name === name);
const hit = exact || users[0];
return hit ? { id: hit.id, displayName: hit.displayName, username: hit.username } : null;
}
/** 投标/报销里的「商务总监」指商务部负责人,不含其他部门误挂 biz_director 的同事。 */
export async function findBizDirectors(db: OrgDb): Promise<OrgPerson[]> {
const inBizDept = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: 'biz_director' } } },
employee: {
department: {
OR: [{ name: { contains: '商务' } }, { code: { contains: 'BIZ' } }, { code: { startsWith: 'OA_4' } }],
},
},
},
select: PERSON_SELECT,
orderBy: { displayName: 'asc' },
});
if (inBizDept.length) return inBizDept;
return db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: 'biz_director' } } },
},
select: PERSON_SELECT,
orderBy: { displayName: 'asc' },
});
}
export async function findRolePerson(db: OrgDb, roleCode: string, exceptId?: string | null): Promise<OrgPerson | null> {
if (roleCode === 'biz_director') {
const users = await findBizDirectors(db);
return users.find((u) => u.id !== exceptId) || users[0] || null;
}
const users = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: roleCode } } },
},
select: PERSON_SELECT,
});
return users.find((u) => u.id !== exceptId) || users[0] || null;
}
function managerUserOf(emp: {
manager?: { users: Array<{ id: string; displayName: string; username: string }> } | null;
} | null): OrgPerson | null {
const u = emp?.manager?.users[0];
return u ? { id: u.id, displayName: u.displayName, username: u.username } : null;
}
export async function resolveExpenseApproverChain(db: OrgDb, applicantId: string) {
const applicant = await db.user.findFirst({
where: { id: applicantId },
include: {
employee: {
include: {
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
},
},
},
});
const [director, pan, bizDirector] = await Promise.all([
findDeptDirector(db, applicant?.employee?.departmentId),
findNamedPerson(db, PAN_NAME),
findRolePerson(db, 'biz_director'),
]);
return assembleApprovalChain({
applicantId,
manager: managerUserOf(applicant?.employee || null),
director,
pan,
bizDirector,
});
}
export async function resolveReportPeople(db: OrgDb, userId: string) {
const me = await db.user.findFirst({
where: { id: userId },
include: {
employee: {
include: {
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
reports: true,
},
},
},
});
const director = await findDeptDirector(db, me?.employee?.departmentId);
const extraIds = parsePeopleIds((me?.employee as { managerIds?: string | null } | undefined)?.managerIds).filter(
(id) => id !== me?.employee?.managerId,
);
const extraManagers = extraIds.length
? await db.user.findMany({
where: { tenantId: '1', status: 'ACTIVE', employeeId: { in: extraIds } },
select: PERSON_SELECT,
})
: [];
return {
me,
manager: managerUserOf(me?.employee || null),
extraManagers,
director,
};
}
+60
View File
@@ -0,0 +1,60 @@
export const TECH_LEAD_ROLES = [
'tech_director',
'rd_director',
'3d_director',
'video_director',
'material_director',
];
/** 商务总监 + 各技术条线总监 */
export const DIRECTOR_ROLES = ['biz_director', ...TECH_LEAD_ROLES];
/** 手工发短信:管理员、人事、各总监。 */
export const SMS_SEND_ROLES = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES];
export function canSendSms(roles: string[], permissions: string[] = []) {
if (hasAnyRole(roles, SMS_SEND_ROLES)) return true;
return permissions.includes('office:sms') || permissions.includes('system:sms');
}
export const TECH_STAFF_ROLES = ['employee', 'pm', 'rd_staff', '3d_staff', 'video_staff', 'material_staff', 'tech'];
export const APPROVER_ROLES = [
'admin',
'owner',
'biz_director',
'biz_staff',
'hr',
'finance',
...TECH_LEAD_ROLES,
];
export function isStaffRole(roles: string[]) {
return roles.length > 0 && roles.every((r) => TECH_STAFF_ROLES.includes(r));
}
export function canApproveOthers(roles: string[], permissions: string[]) {
if (hasAnyRole(roles, APPROVER_ROLES)) return true;
return permissions.includes('office:approvals');
}
export function hasAnyRole(roles: string[], codes: readonly string[]) {
return codes.some((c) => roles.includes(c));
}
export function isTechLead(roles: string[]) {
return hasAnyRole(roles, TECH_LEAD_ROLES);
}
/** 研发总监与系统管理员同等。登录后补上 admin,后端原有管理员判断都会生效。 */
export function elevateToAdmin(roles: string[]) {
if (roles.includes('rd_director') && !roles.includes('admin')) return [...roles, 'admin'];
return roles;
}
/** 总监、项目经理、公司负责人可给下属分配工作。 */
export const WORK_ASSIGN_ROLES = ['admin', 'owner', 'biz_director', 'pm', ...TECH_LEAD_ROLES];
export function canAssignWork(roles: string[]) {
return hasAnyRole(roles, WORK_ASSIGN_ROLES);
}
+288
View File
@@ -0,0 +1,288 @@
function key() {
return process.env.TENCENT_MAP_KEY || '';
}
const REFERER = 'https://oa.fysxkj.com/';
async function tencentGet(path: string, params: Record<string, string>) {
const k = key();
if (!k) return null;
const q = new URLSearchParams({ key: k, output: 'json', ...params });
const res = await fetch(`https://apis.map.qq.com${path}?${q.toString()}`, {
headers: { Referer: REFERER },
});
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
}
export async function locateByIp(ip?: string) {
const json = await tencentGet('/ws/location/v1/ip', ip ? { ip } : {});
const result = json?.result as
| { location?: { lat?: number; lng?: number }; ad_info?: { city?: string; province?: string; district?: string } }
| undefined;
if (json?.status !== 0 || !result?.location) return null;
return {
lat: Number(result.location.lat),
lng: Number(result.location.lng),
city: String(result.ad_info?.city || result.ad_info?.province || '').replace(/市$/, ''),
district: String(result.ad_info?.district || ''),
};
}
export function tencentMapKey() {
return key();
}
export type PlaceHit = {
lat: number;
lng: number;
title: string;
address: string;
city?: string;
distance?: number;
};
function asPoi(p: {
title?: string;
address?: string;
location?: { lat?: number; lng?: number };
_distance?: number | string;
ad_info?: { city?: string };
}): PlaceHit | null {
const lat = Number(p.location?.lat);
const lng = Number(p.location?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const dist = Number(p._distance);
return {
lat,
lng,
title: String(p.title || p.address || '位置'),
address: String(p.address || ''),
city: String(p.ad_info?.city || '').replace(/市$/, ''),
distance: Number.isFinite(dist) ? dist : undefined,
};
}
export async function reverseGeocode(lat: number, lng: number) {
const json = await tencentGet('/ws/geocoder/v1/', {
location: `${lat},${lng}`,
get_poi: '1',
poi_options: 'address_format=short;radius=1000;page_size=20;policy=2',
});
const result = json?.result as
| {
address?: string;
formatted_addresses?: { recommend?: string; rough?: string };
address_component?: { city?: string; province?: string; district?: string };
pois?: Array<{
title?: string;
address?: string;
location?: { lat?: number; lng?: number };
_distance?: number | string;
}>;
}
| undefined;
if (json?.status !== 0 || !result) return null;
const address = String(result.address || '');
const title = String(result.formatted_addresses?.recommend || result.formatted_addresses?.rough || address);
return {
address,
title: title || address,
city: String(result.address_component?.city || result.address_component?.province || '').replace(/市$/, ''),
district: String(result.address_component?.district || ''),
lat,
lng,
pois: (result.pois || []).map(asPoi).filter((p): p is PlaceHit => !!p),
};
}
export async function suggestPlaces(keyword: string, lat?: number, lng?: number) {
const q = keyword.trim();
if (!q) return [] as PlaceHit[];
const params: Record<string, string> = { keyword: q, page_size: '20' };
if (Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))) params.location = `${lat},${lng}`;
const json = await tencentGet('/ws/place/v1/suggestion/', params);
const rows = (json?.data as Array<Parameters<typeof asPoi>[0]>) || [];
return rows.map(asPoi).filter((p): p is PlaceHit => !!p);
}
const CITIES = [
'合肥',
'南京',
'北京',
'上海',
'杭州',
'苏州',
'无锡',
'宁波',
'武汉',
'成都',
'西安',
'广州',
'深圳',
'青岛',
'济南',
'郑州',
'长沙',
'南昌',
'福州',
'厦门',
'天津',
'重庆',
'沈阳',
'大连',
'哈尔滨',
'长春',
'昆明',
'贵阳',
'南宁',
'海口',
'兰州',
'银川',
'西宁',
'太原',
'石家庄',
'呼和浩特',
'乌鲁木齐',
];
function cityHint(text: string) {
return CITIES.find((c) => text.includes(c)) || '';
}
function poiScore(query: string, title: string, addr: string) {
const hay = `${title}${addr}`;
if (title === query) return 1000;
if (title.includes(query) || query.includes(title)) return 800;
let s = 0;
for (const part of query.match(/[\u4e00-\u9fff]{2,}/g) || []) {
if (hay.includes(part)) s += part.length * 10;
}
return s;
}
type GeoHit = {
lat: number;
lng: number;
title: string;
address: string;
city: string;
};
function asHit(lat: number, lng: number, title: string, address: string, city: string): GeoHit {
return { lat, lng, title, address, city };
}
export async function geocodeAddress(address: string) {
const q = address.trim();
if (!q) return null;
const geo = await tencentGet('/ws/geocoder/v1/', { address: q });
const geoResult = geo?.result as
| { location?: { lat?: number; lng?: number }; title?: string; address?: string }
| undefined;
if (geo?.status === 0 && geoResult?.location) {
const extra = await reverseGeocode(Number(geoResult.location.lat), Number(geoResult.location.lng));
return asHit(
Number(geoResult.location.lat),
Number(geoResult.location.lng),
String(geoResult.title || q),
extra?.address || String(geoResult.address || q),
extra?.city || '',
);
}
const city = cityHint(q);
const [sug, search] = await Promise.all([
tencentGet('/ws/place/v1/suggestion/', city ? { keyword: q, region: city } : { keyword: q }),
tencentGet('/ws/place/v1/search/', {
keyword: q,
boundary: city ? `region(${city},0)` : 'region(全国,0)',
page_size: '10',
}),
]);
type Poi = { title?: string; address?: string; location?: { lat?: number; lng?: number }; ad_info?: { city?: string } };
const pois = [...((sug?.data as Poi[]) || []), ...((search?.data as Poi[]) || [])].filter(
(p) => p?.location && Number.isFinite(Number(p.location.lat)),
);
if (!pois.length) return null;
const ranked = [...pois].sort(
(a, b) =>
poiScore(q, String(b.title || ''), String(b.address || '')) -
poiScore(q, String(a.title || ''), String(a.address || '')),
);
const best = ranked[0];
const extra = await reverseGeocode(Number(best.location!.lat), Number(best.location!.lng));
return asHit(
Number(best.location!.lat),
Number(best.location!.lng),
String(best.title || q),
extra?.address || String(best.address || q),
extra?.city || String(best.ad_info?.city || city).replace(/市$/, ''),
);
}
type WeatherNow = {
city: string;
weather: string;
temperature?: number;
humidity?: number;
wind?: string;
advice: string;
};
function adviceFrom(info: string, temp?: number) {
const tips: string[] = [];
if (/雨|雪|雷/.test(info)) tips.push('注意下雨,出门带伞');
if (/晴/.test(info) || (typeof temp === 'number' && temp >= 30)) tips.push('注意紫外线,做好防晒');
if (typeof temp === 'number' && temp >= 35) tips.push('注意防暑降温');
if (typeof temp === 'number' && temp <= 5) tips.push('注意保暖');
if (/雾|霾|沙/.test(info)) tips.push('注意出行能见度');
if (/风/.test(info) && !/微风/.test(info)) tips.push('注意大风');
if (!tips.length) tips.push('注意劳逸结合');
return [...new Set(tips)].slice(0, 2).join('');
}
export async function weatherByLocation(lat: number, lng: number, cityHint?: string): Promise<WeatherNow | null> {
const json = await tencentGet('/ws/weather/v1/', {
location: `${lat},${lng}`,
type: 'now',
added_fields: 'alarm',
});
if (json?.status !== 0) return null;
const result = json.result as { realtime?: Array<Record<string, unknown>> } | undefined;
const row = result?.realtime?.[0];
if (!row) return null;
const infos = (row.infos || {}) as Record<string, unknown>;
const info = String(infos.weather || '—');
const temperature = Number(infos.temperature);
const humidity = Number(infos.humidity);
const windDir = String(infos.wind_direction || '');
const windPower = String(infos.wind_power_v2 || infos.wind_power || '');
const city = String(row.city || cityHint || '').replace(/市$/, '');
return {
city: city || '当地',
weather: info,
temperature: Number.isFinite(temperature) ? temperature : undefined,
humidity: Number.isFinite(humidity) ? humidity : undefined,
wind: [windDir, windPower].filter(Boolean).join(' ') || undefined,
advice: adviceFrom(info, Number.isFinite(temperature) ? temperature : undefined),
};
}
const GREETINGS = [
(n: string) => `尊敬的${n}您好,美好的一天开始了,祝您工作愉快。`,
(n: string) => `尊敬的${n},新的一天已就绪,愿今天经手的每一件事都顺利落地。`,
(n: string) => `${n},早上好。保持节奏,今天也会很出色。`,
(n: string) => `尊敬的${n}您好,愿今天思路清晰、协作顺畅。`,
(n: string) => `${n},新的一天值得认真对待,也值得对自己温柔一点。`,
(n: string) => `尊敬的${n},把难事先拆小步,今天就会轻松很多。`,
(n: string) => `${n}您好,专注当下这一件事,成果会自己跟上。`,
];
export function dailyGreeting(name: string, seed: string) {
const day = new Date().toISOString().slice(0, 10);
let h = 0;
const s = `${day}:${seed}`;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return GREETINGS[h % GREETINGS.length](name || '同事');
}